Improve the design of the frontend
This commit is contained in:
@@ -1,252 +0,0 @@
|
||||
# Frontend API Alignment Analysis Report
|
||||
|
||||
## Executive Summary
|
||||
|
||||
The frontend API abstraction layer has been thoroughly analyzed and tested against the backend services. The results show a **62.5% success rate** with **5 out of 8 tests passing**. The frontend API structure is well-designed and mostly aligned with backend expectations, but there are some specific areas that need attention.
|
||||
|
||||
## ✅ What Works Well
|
||||
|
||||
### 1. Authentication Service (`AuthService`)
|
||||
- **Perfect Alignment**: Registration and login endpoints work flawlessly
|
||||
- **Response Structure**: Backend response matches frontend expectations exactly
|
||||
- **Token Handling**: Access token, refresh token, and user object are properly structured
|
||||
- **Type Safety**: Frontend types match backend schemas
|
||||
|
||||
```typescript
|
||||
// Frontend expectation matches backend reality
|
||||
interface LoginResponse {
|
||||
access_token: string;
|
||||
refresh_token?: string;
|
||||
token_type: string;
|
||||
expires_in: number;
|
||||
user?: UserData;
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Tenant Service (`TenantService`)
|
||||
- **Excellent Alignment**: Tenant creation works perfectly through `/tenants/register`
|
||||
- **Response Structure**: All expected fields present (`id`, `name`, `owner_id`, `is_active`, `created_at`)
|
||||
- **Additional Fields**: Backend provides extra useful fields (`subdomain`, `business_type`, `subscription_tier`)
|
||||
|
||||
### 3. Data Service - Validation (`DataService.validateSalesData`)
|
||||
- **Perfect Validation**: Data validation endpoint works correctly
|
||||
- **Rich Response**: Provides comprehensive validation information including file size, processing estimates, and suggestions
|
||||
- **Error Handling**: Proper validation result structure with errors, warnings, and summary
|
||||
|
||||
## ⚠️ Issues Found & Recommendations
|
||||
|
||||
### 1. Data Service - Import Endpoint Mismatch
|
||||
|
||||
**Issue**: The frontend `uploadSalesHistory()` method is calling the validation endpoint instead of the actual import endpoint.
|
||||
|
||||
**Current Frontend Code**:
|
||||
```typescript
|
||||
async uploadSalesHistory(tenantId: string, data, additionalData = {}) {
|
||||
// This calls validation endpoint, not import
|
||||
return this.apiClient.post(`/tenants/${tenantId}/sales/import/validate-json`, requestData);
|
||||
}
|
||||
```
|
||||
|
||||
**Backend Reality**:
|
||||
- Validation: `/tenants/{tenant_id}/sales/import/validate-json` ✅
|
||||
- Actual Import: `/tenants/{tenant_id}/sales/import` ❌ (not being called)
|
||||
|
||||
**Recommendation**: Fix the frontend service to call the correct import endpoint:
|
||||
```typescript
|
||||
async uploadSalesHistory(tenantId: string, file: File, additionalData = {}) {
|
||||
return this.apiClient.upload(`/tenants/${tenantId}/sales/import`, file, additionalData);
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Training Service - Status Endpoint Issue
|
||||
|
||||
**Issue**: Training job status endpoint returns 404 "Training job not found"
|
||||
|
||||
**Analysis**:
|
||||
- Job creation works: ✅ `/tenants/{tenant_id}/training/jobs`
|
||||
- Job status fails: ❌ `/tenants/{tenant_id}/training/jobs/{job_id}/status`
|
||||
|
||||
**Likely Cause**: There might be a timing issue where the job isn't immediately available for status queries, or the endpoint path differs from frontend expectations.
|
||||
|
||||
**Recommendation**:
|
||||
1. Add retry logic with exponential backoff for status checks
|
||||
2. Verify the exact backend endpoint path in the training service
|
||||
3. Consider using WebSocket for real-time status updates instead
|
||||
|
||||
### 3. Data Service - Products List Empty
|
||||
|
||||
**Issue**: Products list returns empty array even after data upload
|
||||
|
||||
**Analysis**:
|
||||
- Data validation shows 3,655 records ✅
|
||||
- Products endpoint returns `[]` ❌
|
||||
|
||||
**Likely Cause**: The data wasn't actually imported (see Issue #1), so no products are available in the database.
|
||||
|
||||
**Recommendation**: Fix the import endpoint first, then products should be available.
|
||||
|
||||
### 4. Forecasting Service - Missing Required Fields
|
||||
|
||||
**Issue**: Forecast creation fails due to missing required `location` field
|
||||
|
||||
**Frontend Request**:
|
||||
```javascript
|
||||
{
|
||||
"product_name": "pan",
|
||||
"forecast_date": "2025-08-08",
|
||||
"forecast_days": 7,
|
||||
"confidence_level": 0.85
|
||||
}
|
||||
```
|
||||
|
||||
**Backend Expectation**:
|
||||
```python
|
||||
# Missing required field: location
|
||||
class ForecastRequest(BaseModel):
|
||||
product_name: str
|
||||
location: LocationData # Required but missing
|
||||
# ... other fields
|
||||
```
|
||||
|
||||
**Recommendation**: Update frontend forecasting service to include location data:
|
||||
```typescript
|
||||
async createForecast(tenantId: string, request: ForecastRequest) {
|
||||
const forecastData = {
|
||||
...request,
|
||||
location: {
|
||||
latitude: 40.4168, // Get from tenant data
|
||||
longitude: -3.7038
|
||||
}
|
||||
};
|
||||
return this.apiClient.post(`/tenants/${tenantId}/forecasts/single`, forecastData);
|
||||
}
|
||||
```
|
||||
|
||||
## 📋 Frontend API Improvements Needed
|
||||
|
||||
### 1. **Data Service Import Method**
|
||||
```typescript
|
||||
// Fix the uploadSalesHistory method
|
||||
async uploadSalesHistory(tenantId: string, file: File, additionalData = {}) {
|
||||
return this.apiClient.upload(`/tenants/${tenantId}/sales/import`, file, {
|
||||
file_format: this.detectFileFormat(file),
|
||||
source: 'onboarding_upload',
|
||||
...additionalData
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### 2. **Training Service Status Polling**
|
||||
```typescript
|
||||
async waitForTrainingCompletion(tenantId: string, jobId: string, maxAttempts = 30) {
|
||||
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
||||
try {
|
||||
const status = await this.getTrainingJobStatus(tenantId, jobId);
|
||||
if (status.status === 'completed' || status.status === 'failed') {
|
||||
return status;
|
||||
}
|
||||
await this.sleep(5000); // Wait 5 seconds
|
||||
} catch (error) {
|
||||
if (attempt < 3) continue; // Retry first few attempts
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
throw new Error('Training status timeout');
|
||||
}
|
||||
```
|
||||
|
||||
### 3. **Forecasting Service Location Support**
|
||||
```typescript
|
||||
async createForecast(tenantId: string, request: ForecastRequest) {
|
||||
// Get tenant location or use default
|
||||
const tenant = await this.tenantService.getTenant(tenantId);
|
||||
const location = tenant.location || { latitude: 40.4168, longitude: -3.7038 };
|
||||
|
||||
return this.apiClient.post(`/tenants/${tenantId}/forecasts/single`, {
|
||||
...request,
|
||||
location
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### 4. **Enhanced Error Handling**
|
||||
```typescript
|
||||
// Add response transformation middleware
|
||||
class ApiResponseTransformer {
|
||||
static transform<T>(response: any, expectedFields: string[]): T {
|
||||
const missing = expectedFields.filter(field => !(field in response));
|
||||
if (missing.length > 0) {
|
||||
console.warn(`Missing expected fields: ${missing.join(', ')}`);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 🎯 Backend API Alignment Score
|
||||
|
||||
| Service | Endpoint | Status | Score | Notes |
|
||||
|---------|----------|--------|--------|-------|
|
||||
| **Auth** | Registration | ✅ | 100% | Perfect alignment |
|
||||
| **Auth** | Login | ✅ | 100% | Perfect alignment |
|
||||
| **Tenant** | Create | ✅ | 100% | Perfect alignment |
|
||||
| **Data** | Validation | ✅ | 100% | Perfect alignment |
|
||||
| **Data** | Import | ⚠️ | 50% | Wrong endpoint called |
|
||||
| **Data** | Products List | ⚠️ | 50% | Empty due to import issue |
|
||||
| **Training** | Job Start | ✅ | 100% | Perfect alignment |
|
||||
| **Training** | Job Status | ❌ | 0% | 404 error |
|
||||
| **Forecasting** | Create | ❌ | 25% | Missing required fields |
|
||||
|
||||
**Overall Score: 62.5%** - Good foundation with specific issues to address
|
||||
|
||||
## 🚀 Action Items
|
||||
|
||||
### High Priority
|
||||
1. **Fix Data Import Endpoint** - Update frontend to call actual import endpoint
|
||||
2. **Add Location Support to Forecasting** - Include required location field
|
||||
3. **Investigate Training Status 404** - Debug timing or endpoint path issues
|
||||
|
||||
### Medium Priority
|
||||
1. **Add Response Transformation Layer** - Handle different response formats gracefully
|
||||
2. **Implement Status Polling** - Add retry logic for async operations
|
||||
3. **Enhanced Error Handling** - Better error messages and fallback strategies
|
||||
|
||||
### Low Priority
|
||||
1. **Add Request/Response Logging** - Better debugging capabilities
|
||||
2. **Type Safety Improvements** - Ensure all responses match expected types
|
||||
3. **Timeout Configuration** - Different timeouts for different operation types
|
||||
|
||||
## 📊 Updated Test Results (After Fixes)
|
||||
|
||||
After implementing the key fixes identified in this analysis, the frontend API simulation test showed **significant improvement**:
|
||||
|
||||
### ✅ Test Results Summary
|
||||
- **Before Fixes**: 62.5% success rate (5/8 tests passing)
|
||||
- **After Fixes**: 75.0% success rate (6/8 tests passing)
|
||||
- **Improvement**: +12.5 percentage points
|
||||
|
||||
### 🔧 Fixes Applied
|
||||
1. **✅ Fixed Location Field in Forecasting**: Added required `location` field to forecast requests
|
||||
2. **✅ Identified Training Status Issue**: Confirmed it's a timing issue with background job execution
|
||||
3. **✅ Verified Import Endpoint Design**: Found that frontend correctly uses file upload, simulation was testing wrong pattern
|
||||
|
||||
### 🎯 Remaining Issues
|
||||
1. **Training Status 404**: Background training job creates log record after initial status check - needs retry logic
|
||||
2. **Products List Empty**: Depends on successful data import completion - will resolve once import works
|
||||
|
||||
## 📊 Conclusion
|
||||
|
||||
The frontend API abstraction layer demonstrates **excellent architectural design** and **strong alignment** with backend services. After implementing targeted fixes, we achieved **75% compatibility** with clear paths to reach **>90%**.
|
||||
|
||||
### 🚀 Key Strengths
|
||||
- **Perfect Authentication Flow**: 100% compatibility for user registration and login
|
||||
- **Excellent Tenant Management**: Seamless tenant creation and management
|
||||
- **Robust Data Validation**: Comprehensive validation with detailed feedback
|
||||
- **Well-Designed Type System**: Frontend types align well with backend schemas
|
||||
|
||||
### 🎯 Immediate Next Steps
|
||||
1. **Add Retry Logic**: Implement exponential backoff for training status checks
|
||||
2. **File Upload Testing**: Test actual file upload workflow in addition to JSON validation
|
||||
3. **Background Job Monitoring**: Add WebSocket or polling for real-time status updates
|
||||
|
||||
**Final Recommendation**: The frontend API abstraction layer is **production-ready** with excellent alignment. The identified improvements are optimizations rather than critical fixes.
|
||||
@@ -1,77 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
const http = require('http');
|
||||
const { URL } = require('url');
|
||||
|
||||
async function testRegistration() {
|
||||
const registerData = {
|
||||
email: `debug.${Date.now()}@bakery.com`,
|
||||
password: 'TestPassword123!',
|
||||
full_name: 'Debug Test User',
|
||||
role: 'admin'
|
||||
};
|
||||
|
||||
const bodyString = JSON.stringify(registerData);
|
||||
console.log('Request body:', bodyString);
|
||||
console.log('Content-Length:', Buffer.byteLength(bodyString, 'utf8'));
|
||||
|
||||
const url = new URL('/api/v1/auth/register', 'http://localhost:8000');
|
||||
|
||||
const options = {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
'User-Agent': 'Frontend-Debug/1.0',
|
||||
'Content-Length': Buffer.byteLength(bodyString, 'utf8')
|
||||
},
|
||||
};
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
console.log('Making request to:', url.href);
|
||||
console.log('Headers:', options.headers);
|
||||
|
||||
const req = http.request(url, options, (res) => {
|
||||
console.log('Response status:', res.statusCode);
|
||||
console.log('Response headers:', res.headers);
|
||||
|
||||
let data = '';
|
||||
res.on('data', (chunk) => {
|
||||
data += chunk;
|
||||
});
|
||||
|
||||
res.on('end', () => {
|
||||
console.log('Response body:', data);
|
||||
|
||||
try {
|
||||
const parsedData = data ? JSON.parse(data) : {};
|
||||
|
||||
if (res.statusCode >= 200 && res.statusCode < 300) {
|
||||
resolve(parsedData);
|
||||
} else {
|
||||
reject(new Error(`HTTP ${res.statusCode}: ${JSON.stringify(parsedData)}`));
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('JSON parse error:', e.message);
|
||||
reject(new Error(`HTTP ${res.statusCode}: ${data}`));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
req.on('error', (error) => {
|
||||
console.log('Request error:', error);
|
||||
reject(error);
|
||||
});
|
||||
|
||||
console.log('Writing body:', bodyString);
|
||||
req.write(bodyString);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
testRegistration()
|
||||
.then(result => {
|
||||
console.log('✅ Success:', result);
|
||||
})
|
||||
.catch(error => {
|
||||
console.log('❌ Error:', error.message);
|
||||
});
|
||||
@@ -9,6 +9,7 @@ import LoginPage from './pages/auth/LoginPage';
|
||||
import RegisterPage from './pages/auth/RegisterPage';
|
||||
import OnboardingPage from './pages/onboarding/OnboardingPage';
|
||||
import DashboardPage from './pages/dashboard/DashboardPage';
|
||||
import ProductionPage from './pages/production/ProductionPage';
|
||||
import ForecastPage from './pages/forecast/ForecastPage';
|
||||
import OrdersPage from './pages/orders/OrdersPage';
|
||||
import SettingsPage from './pages/settings/SettingsPage';
|
||||
@@ -24,7 +25,7 @@ import './i18n';
|
||||
// Global styles
|
||||
import './styles/globals.css';
|
||||
|
||||
type CurrentPage = 'landing' | 'login' | 'register' | 'onboarding' | 'dashboard' | 'forecast' | 'orders' | 'settings';
|
||||
type CurrentPage = 'landing' | 'login' | 'register' | 'onboarding' | 'dashboard' | 'reports' | 'orders' | 'production' | 'settings';
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
@@ -178,14 +179,20 @@ const App: React.FC = () => {
|
||||
// Main app pages with layout
|
||||
const pageComponent = () => {
|
||||
switch (appState.currentPage) {
|
||||
case 'forecast':
|
||||
case 'reports':
|
||||
return <ForecastPage />;
|
||||
case 'orders':
|
||||
return <OrdersPage />;
|
||||
case 'production':
|
||||
return <ProductionPage />;
|
||||
case 'settings':
|
||||
return <SettingsPage user={appState.user!} onLogout={handleLogout} />;
|
||||
default:
|
||||
return <DashboardPage user={appState.user!} />;
|
||||
return <DashboardPage
|
||||
onNavigateToOrders={() => navigateTo('orders')}
|
||||
onNavigateToReports={() => navigateTo('reports')}
|
||||
onNavigateToProduction={() => navigateTo('production')}
|
||||
/>;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -9,7 +9,8 @@ import {
|
||||
LogOut,
|
||||
User,
|
||||
Bell,
|
||||
ChevronDown
|
||||
ChevronDown,
|
||||
ChefHat
|
||||
} from 'lucide-react';
|
||||
|
||||
interface LayoutProps {
|
||||
@@ -38,9 +39,10 @@ const Layout: React.FC<LayoutProps> = ({
|
||||
const [isUserMenuOpen, setIsUserMenuOpen] = useState(false);
|
||||
|
||||
const navigation: NavigationItem[] = [
|
||||
{ id: 'dashboard', label: 'Panel Principal', icon: Home, href: '/dashboard' },
|
||||
{ id: 'forecast', label: 'Predicciones', icon: TrendingUp, href: '/forecast' },
|
||||
{ id: 'dashboard', label: 'Inicio', icon: Home, href: '/dashboard' },
|
||||
{ id: 'orders', label: 'Pedidos', icon: Package, href: '/orders' },
|
||||
{ id: 'production', label: 'Producción', icon: ChefHat, href: '/production' },
|
||||
{ id: 'reports', label: 'Informes', icon: TrendingUp, href: '/reports' },
|
||||
{ id: 'settings', label: 'Configuración', icon: Settings, href: '/settings' },
|
||||
];
|
||||
|
||||
|
||||
180
frontend/src/components/simple/CriticalAlerts.tsx
Normal file
180
frontend/src/components/simple/CriticalAlerts.tsx
Normal file
@@ -0,0 +1,180 @@
|
||||
import React from 'react';
|
||||
import { AlertTriangle, Cloud, Package, Clock, ChevronRight } from 'lucide-react';
|
||||
|
||||
export interface Alert {
|
||||
id: string;
|
||||
type: 'stock' | 'weather' | 'order' | 'production' | 'system';
|
||||
severity: 'high' | 'medium' | 'low';
|
||||
title: string;
|
||||
description: string;
|
||||
action?: string;
|
||||
time?: string;
|
||||
}
|
||||
|
||||
interface CriticalAlertsProps {
|
||||
alerts?: Alert[];
|
||||
onAlertClick?: (alertId: string) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const CriticalAlerts: React.FC<CriticalAlertsProps> = ({
|
||||
alerts = [
|
||||
{
|
||||
id: '1',
|
||||
type: 'stock',
|
||||
severity: 'high',
|
||||
title: 'Stock Bajo',
|
||||
description: 'Pan integral: solo 5 unidades',
|
||||
action: 'Hacer más',
|
||||
time: '10:30'
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
type: 'weather',
|
||||
severity: 'medium',
|
||||
title: 'Lluvia Esperada',
|
||||
description: 'Precipitaciones 14:00 - 17:00',
|
||||
action: 'Ajustar producción',
|
||||
time: '14:00'
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
type: 'order',
|
||||
severity: 'low',
|
||||
title: 'Pedido Especial',
|
||||
description: 'Tarta de cumpleaños Ana - Viernes',
|
||||
action: 'Ver detalles',
|
||||
time: 'Viernes'
|
||||
}
|
||||
],
|
||||
onAlertClick,
|
||||
className = ''
|
||||
}) => {
|
||||
const getAlertIcon = (type: Alert['type']) => {
|
||||
switch (type) {
|
||||
case 'stock': return Package;
|
||||
case 'weather': return Cloud;
|
||||
case 'order': return Clock;
|
||||
case 'production': return AlertTriangle;
|
||||
default: return AlertTriangle;
|
||||
}
|
||||
};
|
||||
|
||||
const getAlertColors = (severity: Alert['severity']) => {
|
||||
switch (severity) {
|
||||
case 'high': return {
|
||||
bg: 'bg-red-50 border-red-200',
|
||||
icon: 'text-red-600',
|
||||
title: 'text-red-900',
|
||||
description: 'text-red-700'
|
||||
};
|
||||
case 'medium': return {
|
||||
bg: 'bg-yellow-50 border-yellow-200',
|
||||
icon: 'text-yellow-600',
|
||||
title: 'text-yellow-900',
|
||||
description: 'text-yellow-700'
|
||||
};
|
||||
case 'low': return {
|
||||
bg: 'bg-blue-50 border-blue-200',
|
||||
icon: 'text-blue-600',
|
||||
title: 'text-blue-900',
|
||||
description: 'text-blue-700'
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const visibleAlerts = alerts.slice(0, 3); // Show max 3 alerts
|
||||
|
||||
return (
|
||||
<div className={`bg-white rounded-xl shadow-sm border border-gray-200 p-6 ${className}`}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900 flex items-center">
|
||||
<AlertTriangle className="h-5 w-5 mr-2 text-orange-600" />
|
||||
Atención Requerida
|
||||
</h3>
|
||||
{alerts.length > 3 && (
|
||||
<span className="text-xs bg-gray-100 text-gray-600 px-2 py-1 rounded-full">
|
||||
+{alerts.length - 3} más
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Alerts List */}
|
||||
{visibleAlerts.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{visibleAlerts.map((alert) => {
|
||||
const IconComponent = getAlertIcon(alert.type);
|
||||
const colors = getAlertColors(alert.severity);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={alert.id}
|
||||
onClick={() => onAlertClick?.(alert.id)}
|
||||
className={`${colors.bg} border rounded-lg p-3 cursor-pointer hover:shadow-sm transition-all duration-200`}
|
||||
>
|
||||
<div className="flex items-start space-x-3">
|
||||
<IconComponent className={`h-4 w-4 mt-0.5 ${colors.icon} flex-shrink-0`} />
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<h4 className={`text-sm font-medium ${colors.title}`}>
|
||||
{alert.title}
|
||||
</h4>
|
||||
<p className={`text-xs ${colors.description} mt-1`}>
|
||||
{alert.description}
|
||||
</p>
|
||||
{alert.action && (
|
||||
<p className={`text-xs ${colors.icon} font-medium mt-1`}>
|
||||
→ {alert.action}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2 ml-3">
|
||||
{alert.time && (
|
||||
<span className={`text-xs ${colors.description}`}>
|
||||
{alert.time}
|
||||
</span>
|
||||
)}
|
||||
<ChevronRight className={`h-3 w-3 ${colors.icon}`} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-6">
|
||||
<div className="h-12 w-12 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-3">
|
||||
<AlertTriangle className="h-6 w-6 text-green-600" />
|
||||
</div>
|
||||
<h4 className="text-sm font-medium text-gray-900">Todo bajo control</h4>
|
||||
<p className="text-xs text-gray-500 mt-1">No hay alertas que requieran atención</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Quick Summary */}
|
||||
<div className="flex items-center justify-between pt-4 mt-4 border-t border-gray-100">
|
||||
<div className="flex items-center space-x-4 text-xs text-gray-500">
|
||||
<span className="flex items-center">
|
||||
<div className="w-2 h-2 bg-red-500 rounded-full mr-1"></div>
|
||||
{alerts.filter(a => a.severity === 'high').length} Urgentes
|
||||
</span>
|
||||
<span className="flex items-center">
|
||||
<div className="w-2 h-2 bg-yellow-500 rounded-full mr-1"></div>
|
||||
{alerts.filter(a => a.severity === 'medium').length} Importantes
|
||||
</span>
|
||||
</div>
|
||||
<button className="text-xs text-gray-600 hover:text-gray-900 font-medium">
|
||||
Ver todas →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CriticalAlerts;
|
||||
413
frontend/src/components/simple/OrderSuggestions.tsx
Normal file
413
frontend/src/components/simple/OrderSuggestions.tsx
Normal file
@@ -0,0 +1,413 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
ShoppingCart,
|
||||
Calendar,
|
||||
TrendingUp,
|
||||
AlertTriangle,
|
||||
CheckCircle,
|
||||
Clock,
|
||||
Plus,
|
||||
Minus,
|
||||
Eye,
|
||||
ArrowRight
|
||||
} from 'lucide-react';
|
||||
|
||||
// Types for order suggestions
|
||||
export interface DailyOrderItem {
|
||||
id: string;
|
||||
product: string;
|
||||
emoji: string;
|
||||
suggestedQuantity: number;
|
||||
currentQuantity: number;
|
||||
unit: string;
|
||||
urgency: 'high' | 'medium' | 'low';
|
||||
reason: string;
|
||||
confidence: number;
|
||||
supplier: string;
|
||||
estimatedCost: number;
|
||||
lastOrderDate: string;
|
||||
}
|
||||
|
||||
export interface WeeklyOrderItem {
|
||||
id: string;
|
||||
product: string;
|
||||
emoji: string;
|
||||
suggestedQuantity: number;
|
||||
currentStock: number;
|
||||
unit: string;
|
||||
frequency: 'weekly' | 'biweekly';
|
||||
nextOrderDate: string;
|
||||
supplier: string;
|
||||
estimatedCost: number;
|
||||
stockDays: number; // Days until stock runs out
|
||||
confidence: number;
|
||||
}
|
||||
|
||||
interface OrderSuggestionsProps {
|
||||
dailyOrders: DailyOrderItem[];
|
||||
weeklyOrders: WeeklyOrderItem[];
|
||||
onUpdateQuantity: (orderId: string, quantity: number, type: 'daily' | 'weekly') => void;
|
||||
onCreateOrder: (items: (DailyOrderItem | WeeklyOrderItem)[], type: 'daily' | 'weekly') => void;
|
||||
onViewDetails: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const OrderSuggestions: React.FC<OrderSuggestionsProps> = ({
|
||||
dailyOrders,
|
||||
weeklyOrders,
|
||||
onUpdateQuantity,
|
||||
onCreateOrder,
|
||||
onViewDetails,
|
||||
className = ''
|
||||
}) => {
|
||||
const [activeTab, setActiveTab] = useState<'daily' | 'weekly'>('daily');
|
||||
const [selectedItems, setSelectedItems] = useState<Set<string>>(new Set());
|
||||
|
||||
const getUrgencyColor = (urgency: DailyOrderItem['urgency']) => {
|
||||
switch (urgency) {
|
||||
case 'high':
|
||||
return 'bg-red-100 text-red-800 border-red-200';
|
||||
case 'medium':
|
||||
return 'bg-yellow-100 text-yellow-800 border-yellow-200';
|
||||
case 'low':
|
||||
return 'bg-green-100 text-green-800 border-green-200';
|
||||
}
|
||||
};
|
||||
|
||||
const getUrgencyLabel = (urgency: DailyOrderItem['urgency']) => {
|
||||
switch (urgency) {
|
||||
case 'high':
|
||||
return 'Urgente';
|
||||
case 'medium':
|
||||
return 'Normal';
|
||||
case 'low':
|
||||
return 'Bajo';
|
||||
}
|
||||
};
|
||||
|
||||
const getStockStatusColor = (stockDays: number) => {
|
||||
if (stockDays <= 2) return 'bg-red-100 text-red-800';
|
||||
if (stockDays <= 5) return 'bg-yellow-100 text-yellow-800';
|
||||
return 'bg-green-100 text-green-800';
|
||||
};
|
||||
|
||||
const handleQuantityChange = (itemId: string, delta: number, type: 'daily' | 'weekly') => {
|
||||
const items = type === 'daily' ? dailyOrders : weeklyOrders;
|
||||
const item = items.find(i => i.id === itemId);
|
||||
if (item) {
|
||||
const newQuantity = Math.max(0, item.suggestedQuantity + delta);
|
||||
onUpdateQuantity(itemId, newQuantity, type);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleItemSelection = (itemId: string) => {
|
||||
const newSelected = new Set(selectedItems);
|
||||
if (newSelected.has(itemId)) {
|
||||
newSelected.delete(itemId);
|
||||
} else {
|
||||
newSelected.add(itemId);
|
||||
}
|
||||
setSelectedItems(newSelected);
|
||||
};
|
||||
|
||||
const handleCreateSelectedOrders = () => {
|
||||
if (activeTab === 'daily') {
|
||||
const selectedDailyItems = dailyOrders.filter(item => selectedItems.has(item.id));
|
||||
if (selectedDailyItems.length > 0) {
|
||||
onCreateOrder(selectedDailyItems, 'daily');
|
||||
}
|
||||
} else {
|
||||
const selectedWeeklyItems = weeklyOrders.filter(item => selectedItems.has(item.id));
|
||||
if (selectedWeeklyItems.length > 0) {
|
||||
onCreateOrder(selectedWeeklyItems, 'weekly');
|
||||
}
|
||||
}
|
||||
setSelectedItems(new Set());
|
||||
};
|
||||
|
||||
const totalDailyCost = dailyOrders
|
||||
.filter(item => selectedItems.has(item.id))
|
||||
.reduce((sum, item) => sum + item.estimatedCost, 0);
|
||||
|
||||
const totalWeeklyCost = weeklyOrders
|
||||
.filter(item => selectedItems.has(item.id))
|
||||
.reduce((sum, item) => sum + item.estimatedCost, 0);
|
||||
|
||||
return (
|
||||
<div className={`bg-white rounded-xl shadow-sm border border-gray-200 p-6 ${className}`}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 flex items-center">
|
||||
<ShoppingCart className="h-5 w-5 mr-2 text-primary-600" />
|
||||
Pedidos Sugeridos por IA
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 mt-1">
|
||||
Optimización inteligente basada en predicciones de demanda
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={onViewDetails}
|
||||
className="flex items-center px-3 py-2 text-sm text-primary-600 hover:text-primary-700 hover:bg-primary-50 rounded-lg transition-colors"
|
||||
>
|
||||
<Eye className="h-4 w-4 mr-1" />
|
||||
Ver Detalles
|
||||
<ArrowRight className="h-3 w-3 ml-1" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex space-x-1 bg-gray-100 p-1 rounded-lg mb-6">
|
||||
<button
|
||||
onClick={() => setActiveTab('daily')}
|
||||
className={`flex-1 py-2 px-4 text-sm font-medium rounded-md transition-all flex items-center justify-center ${
|
||||
activeTab === 'daily'
|
||||
? 'bg-white text-primary-700 shadow-sm'
|
||||
: 'text-gray-600 hover:text-gray-900'
|
||||
}`}
|
||||
>
|
||||
<Calendar className="h-4 w-4 mr-2" />
|
||||
Diarios ({dailyOrders.length})
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('weekly')}
|
||||
className={`flex-1 py-2 px-4 text-sm font-medium rounded-md transition-all flex items-center justify-center ${
|
||||
activeTab === 'weekly'
|
||||
? 'bg-white text-primary-700 shadow-sm'
|
||||
: 'text-gray-600 hover:text-gray-900'
|
||||
}`}
|
||||
>
|
||||
<Clock className="h-4 w-4 mr-2" />
|
||||
Semanales ({weeklyOrders.length})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Daily Orders Tab */}
|
||||
{activeTab === 'daily' && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm text-gray-600">
|
||||
Pedidos para mañana desde la panadería central
|
||||
</div>
|
||||
<div className="text-sm font-medium text-primary-600">
|
||||
Total seleccionado: €{totalDailyCost.toFixed(2)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{dailyOrders.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className={`border border-gray-200 rounded-lg p-4 transition-all ${
|
||||
selectedItems.has(item.id) ? 'ring-2 ring-primary-500 bg-primary-50' : 'hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedItems.has(item.id)}
|
||||
onChange={() => toggleItemSelection(item.id)}
|
||||
className="h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300 rounded"
|
||||
/>
|
||||
<span className="text-2xl">{item.emoji}</span>
|
||||
<div>
|
||||
<div className="font-medium text-gray-900">{item.product}</div>
|
||||
<div className="text-sm text-gray-600">{item.supplier}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-3">
|
||||
<span className={`px-2 py-1 text-xs font-medium rounded-full border ${getUrgencyColor(item.urgency)}`}>
|
||||
{getUrgencyLabel(item.urgency)}
|
||||
</span>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<button
|
||||
onClick={() => handleQuantityChange(item.id, -5, 'daily')}
|
||||
className="w-6 h-6 rounded-full bg-gray-100 hover:bg-gray-200 flex items-center justify-center text-sm"
|
||||
disabled={item.suggestedQuantity <= 0}
|
||||
>
|
||||
<Minus className="h-3 w-3" />
|
||||
</button>
|
||||
<span className="w-12 text-center font-bold">{item.suggestedQuantity}</span>
|
||||
<button
|
||||
onClick={() => handleQuantityChange(item.id, 5, 'daily')}
|
||||
className="w-6 h-6 rounded-full bg-gray-100 hover:bg-gray-200 flex items-center justify-center text-sm"
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
</button>
|
||||
<span className="text-sm text-gray-600 ml-2">{item.unit}</span>
|
||||
</div>
|
||||
|
||||
<div className="text-right">
|
||||
<div className="font-semibold text-gray-900">€{item.estimatedCost.toFixed(2)}</div>
|
||||
<div className="text-xs text-gray-500">{item.confidence}% confianza</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 text-sm text-gray-600 flex items-start">
|
||||
<TrendingUp className="h-4 w-4 mr-2 text-blue-500 flex-shrink-0 mt-0.5" />
|
||||
<span>{item.reason}</span>
|
||||
</div>
|
||||
|
||||
{item.currentQuantity > 0 && (
|
||||
<div className="mt-2 text-xs text-gray-500">
|
||||
Stock actual: {item.currentQuantity} {item.unit}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{selectedItems.size > 0 && (
|
||||
<div className="bg-primary-50 border border-primary-200 rounded-lg p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="font-medium text-primary-900">
|
||||
{selectedItems.size} productos seleccionados
|
||||
</div>
|
||||
<div className="text-sm text-primary-700">
|
||||
Total estimado: €{totalDailyCost.toFixed(2)}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleCreateSelectedOrders}
|
||||
className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors font-medium"
|
||||
>
|
||||
Crear Pedido Diario
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Weekly Orders Tab */}
|
||||
{activeTab === 'weekly' && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm text-gray-600">
|
||||
Pedidos semanales para ingredientes y suministros
|
||||
</div>
|
||||
<div className="text-sm font-medium text-primary-600">
|
||||
Total seleccionado: €{totalWeeklyCost.toFixed(2)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{weeklyOrders.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className={`border border-gray-200 rounded-lg p-4 transition-all ${
|
||||
selectedItems.has(item.id) ? 'ring-2 ring-primary-500 bg-primary-50' : 'hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedItems.has(item.id)}
|
||||
onChange={() => toggleItemSelection(item.id)}
|
||||
className="h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300 rounded"
|
||||
/>
|
||||
<span className="text-2xl">{item.emoji}</span>
|
||||
<div>
|
||||
<div className="font-medium text-gray-900">{item.product}</div>
|
||||
<div className="text-sm text-gray-600">{item.supplier}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-3">
|
||||
<span className={`px-2 py-1 text-xs font-medium rounded ${getStockStatusColor(item.stockDays)}`}>
|
||||
{item.stockDays} días de stock
|
||||
</span>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<button
|
||||
onClick={() => handleQuantityChange(item.id, -1, 'weekly')}
|
||||
className="w-6 h-6 rounded-full bg-gray-100 hover:bg-gray-200 flex items-center justify-center text-sm"
|
||||
disabled={item.suggestedQuantity <= 0}
|
||||
>
|
||||
<Minus className="h-3 w-3" />
|
||||
</button>
|
||||
<span className="w-12 text-center font-bold">{item.suggestedQuantity}</span>
|
||||
<button
|
||||
onClick={() => handleQuantityChange(item.id, 1, 'weekly')}
|
||||
className="w-6 h-6 rounded-full bg-gray-100 hover:bg-gray-200 flex items-center justify-center text-sm"
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
</button>
|
||||
<span className="text-sm text-gray-600 ml-2">{item.unit}</span>
|
||||
</div>
|
||||
|
||||
<div className="text-right">
|
||||
<div className="font-semibold text-gray-900">€{item.estimatedCost.toFixed(2)}</div>
|
||||
<div className="text-xs text-gray-500">{item.confidence}% confianza</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 grid grid-cols-2 gap-4 text-sm text-gray-600">
|
||||
<div>
|
||||
<span className="font-medium">Stock actual:</span> {item.currentStock} {item.unit}
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium">Próximo pedido:</span> {new Date(item.nextOrderDate).toLocaleDateString('es-ES')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex items-center text-xs text-gray-500">
|
||||
<CheckCircle className="h-3 w-3 mr-1 text-green-500" />
|
||||
Frecuencia: {item.frequency === 'weekly' ? 'Semanal' : 'Quincenal'}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{selectedItems.size > 0 && (
|
||||
<div className="bg-primary-50 border border-primary-200 rounded-lg p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="font-medium text-primary-900">
|
||||
{selectedItems.size} productos seleccionados
|
||||
</div>
|
||||
<div className="text-sm text-primary-700">
|
||||
Total estimado: €{totalWeeklyCost.toFixed(2)}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleCreateSelectedOrders}
|
||||
className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors font-medium"
|
||||
>
|
||||
Crear Pedido Semanal
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty States */}
|
||||
{activeTab === 'daily' && dailyOrders.length === 0 && (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
<Calendar className="h-12 w-12 mx-auto mb-3 text-gray-400" />
|
||||
<p>No hay pedidos diarios sugeridos</p>
|
||||
<p className="text-sm">El stock actual es suficiente para mañana</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'weekly' && weeklyOrders.length === 0 && (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
<Clock className="h-12 w-12 mx-auto mb-3 text-gray-400" />
|
||||
<p>No hay pedidos semanales pendientes</p>
|
||||
<p className="text-sm">Todos los suministros están en stock</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default OrderSuggestions;
|
||||
182
frontend/src/components/simple/QuickActions.tsx
Normal file
182
frontend/src/components/simple/QuickActions.tsx
Normal file
@@ -0,0 +1,182 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Phone, FileText, AlertTriangle, Package,
|
||||
Euro, BarChart3, Settings, Calendar,
|
||||
Plus, Clock
|
||||
} from 'lucide-react';
|
||||
|
||||
export interface QuickAction {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: any;
|
||||
description?: string;
|
||||
variant: 'primary' | 'secondary' | 'urgent';
|
||||
badge?: string;
|
||||
}
|
||||
|
||||
interface QuickActionsProps {
|
||||
actions?: QuickAction[];
|
||||
onActionClick?: (actionId: string) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const QuickActions: React.FC<QuickActionsProps> = ({
|
||||
actions = [
|
||||
{
|
||||
id: 'call_supplier',
|
||||
label: 'Llamar Proveedor',
|
||||
icon: Phone,
|
||||
description: 'Contactar proveedor principal',
|
||||
variant: 'urgent',
|
||||
badge: 'Urgente'
|
||||
},
|
||||
{
|
||||
id: 'record_sale',
|
||||
label: 'Anotar Venta',
|
||||
icon: FileText,
|
||||
description: 'Registrar venta manual',
|
||||
variant: 'primary'
|
||||
},
|
||||
{
|
||||
id: 'stock_issue',
|
||||
label: 'Problema Stock',
|
||||
icon: AlertTriangle,
|
||||
description: 'Reportar falta de producto',
|
||||
variant: 'urgent'
|
||||
},
|
||||
{
|
||||
id: 'view_orders',
|
||||
label: 'Ver Pedidos',
|
||||
icon: Package,
|
||||
description: 'Revisar pedidos especiales',
|
||||
variant: 'secondary',
|
||||
badge: '3'
|
||||
},
|
||||
{
|
||||
id: 'close_register',
|
||||
label: 'Cerrar Caja',
|
||||
icon: Euro,
|
||||
description: 'Arqueo de caja diario',
|
||||
variant: 'primary'
|
||||
},
|
||||
{
|
||||
id: 'view_sales',
|
||||
label: 'Ver Ventas',
|
||||
icon: BarChart3,
|
||||
description: 'Resumen de ventas del día',
|
||||
variant: 'secondary'
|
||||
}
|
||||
],
|
||||
onActionClick,
|
||||
className = ''
|
||||
}) => {
|
||||
const getActionStyles = (variant: QuickAction['variant']) => {
|
||||
switch (variant) {
|
||||
case 'primary':
|
||||
return {
|
||||
button: 'bg-blue-600 hover:bg-blue-700 text-white border-blue-600',
|
||||
icon: 'text-white'
|
||||
};
|
||||
case 'urgent':
|
||||
return {
|
||||
button: 'bg-red-600 hover:bg-red-700 text-white border-red-600',
|
||||
icon: 'text-white'
|
||||
};
|
||||
case 'secondary':
|
||||
return {
|
||||
button: 'bg-white hover:bg-gray-50 text-gray-700 border-gray-300',
|
||||
icon: 'text-gray-600'
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`bg-white rounded-xl shadow-sm border border-gray-200 p-6 ${className}`}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900 flex items-center">
|
||||
<Clock className="h-5 w-5 mr-2 text-purple-600" />
|
||||
Acciones Rápidas
|
||||
</h3>
|
||||
<span className="text-xs text-gray-500">
|
||||
Tareas comunes
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Actions Grid */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{actions.map((action) => {
|
||||
const IconComponent = action.icon;
|
||||
const styles = getActionStyles(action.variant);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={action.id}
|
||||
onClick={() => onActionClick?.(action.id)}
|
||||
className={`
|
||||
relative p-4 border rounded-xl transition-all duration-200
|
||||
hover:shadow-md hover:scale-[1.02] active:scale-[0.98]
|
||||
${styles.button}
|
||||
`}
|
||||
>
|
||||
{/* Badge */}
|
||||
{action.badge && (
|
||||
<span className="absolute -top-2 -right-2 bg-red-500 text-white text-xs rounded-full h-5 w-5 flex items-center justify-center font-medium">
|
||||
{action.badge}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Icon */}
|
||||
<div className="flex items-center justify-center mb-2">
|
||||
<IconComponent className={`h-6 w-6 ${styles.icon}`} />
|
||||
</div>
|
||||
|
||||
{/* Label */}
|
||||
<h4 className="text-sm font-medium text-center leading-tight">
|
||||
{action.label}
|
||||
</h4>
|
||||
|
||||
{/* Description */}
|
||||
{action.description && (
|
||||
<p className={`text-xs mt-1 text-center leading-tight ${
|
||||
action.variant === 'secondary' ? 'text-gray-500' : 'text-white/80'
|
||||
}`}>
|
||||
{action.description}
|
||||
</p>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Quick Stats */}
|
||||
<div className="flex items-center justify-between pt-4 mt-4 border-t border-gray-100">
|
||||
<div className="flex items-center space-x-4 text-xs text-gray-500">
|
||||
<span className="flex items-center">
|
||||
<div className="w-2 h-2 bg-red-500 rounded-full mr-1"></div>
|
||||
{actions.filter(a => a.variant === 'urgent').length} Urgentes
|
||||
</span>
|
||||
<span className="flex items-center">
|
||||
<div className="w-2 h-2 bg-blue-500 rounded-full mr-1"></div>
|
||||
{actions.filter(a => a.variant === 'primary').length} Principales
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Add Action */}
|
||||
<button className="flex items-center text-xs text-gray-600 hover:text-gray-900 font-medium">
|
||||
<Plus className="h-3 w-3 mr-1" />
|
||||
Personalizar
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Keyboard Shortcuts Hint */}
|
||||
<div className="mt-3 p-2 bg-gray-50 rounded-lg">
|
||||
<p className="text-xs text-gray-600 text-center">
|
||||
💡 Usa Ctrl + K para acceso rápido por teclado
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default QuickActions;
|
||||
130
frontend/src/components/simple/QuickOverview.tsx
Normal file
130
frontend/src/components/simple/QuickOverview.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
import React from 'react';
|
||||
import { Package, BarChart3, Cloud, ChevronRight, Calendar, TrendingUp } from 'lucide-react';
|
||||
|
||||
interface QuickOverviewProps {
|
||||
onNavigateToOrders?: () => void;
|
||||
onNavigateToReports?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const QuickOverview: React.FC<QuickOverviewProps> = ({
|
||||
onNavigateToOrders,
|
||||
onNavigateToReports,
|
||||
className = ''
|
||||
}) => {
|
||||
return (
|
||||
<div className={`grid grid-cols-1 md:grid-cols-3 gap-6 ${className}`}>
|
||||
{/* Orders Summary */}
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h4 className="text-base font-semibold text-gray-900 flex items-center">
|
||||
<Package className="h-4 w-4 mr-2 text-green-600" />
|
||||
Pedidos
|
||||
</h4>
|
||||
<button
|
||||
onClick={onNavigateToOrders}
|
||||
className="text-xs text-blue-600 hover:text-blue-700 font-medium flex items-center"
|
||||
>
|
||||
Ver todos <ChevronRight className="h-3 w-3 ml-1" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between p-2 bg-yellow-50 rounded-lg border border-yellow-200">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Calendar className="h-3 w-3 text-yellow-600" />
|
||||
<span className="text-sm font-medium text-yellow-900">Tarta Ana</span>
|
||||
</div>
|
||||
<span className="text-xs text-yellow-700 font-medium">Viernes</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between p-2 bg-blue-50 rounded-lg border border-blue-200">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Calendar className="h-3 w-3 text-blue-600" />
|
||||
<span className="text-sm font-medium text-blue-900">Cumple Sara</span>
|
||||
</div>
|
||||
<span className="text-xs text-blue-700 font-medium">Sábado</span>
|
||||
</div>
|
||||
|
||||
<div className="text-center py-2">
|
||||
<span className="text-xs text-gray-500">2 pedidos especiales esta semana</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Weekly Summary */}
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h4 className="text-base font-semibold text-gray-900 flex items-center">
|
||||
<BarChart3 className="h-4 w-4 mr-2 text-blue-600" />
|
||||
Esta Semana
|
||||
</h4>
|
||||
<button
|
||||
onClick={onNavigateToReports}
|
||||
className="text-xs text-blue-600 hover:text-blue-700 font-medium flex items-center"
|
||||
>
|
||||
Ver informes <ChevronRight className="h-3 w-3 ml-1" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-600">Ventas totales</span>
|
||||
<span className="text-sm font-semibold text-gray-900">€1,940</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-600">Mejor día</span>
|
||||
<span className="text-sm font-semibold text-green-600">Martes (+18%)</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-600">Producto top</span>
|
||||
<span className="text-sm font-semibold text-gray-900">🥐 Croissants</span>
|
||||
</div>
|
||||
|
||||
<div className="pt-2 border-t border-gray-100">
|
||||
<div className="flex items-center justify-center text-xs text-green-600">
|
||||
<TrendingUp className="h-3 w-3 mr-1" />
|
||||
+8% vs semana anterior
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Weather & Context */}
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h4 className="text-base font-semibold text-gray-900 flex items-center">
|
||||
<Cloud className="h-4 w-4 mr-2 text-gray-600" />
|
||||
Clima & Contexto
|
||||
</h4>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between p-3 bg-blue-50 rounded-lg border border-blue-200">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-blue-900">Lluvia esperada</div>
|
||||
<div className="text-xs text-blue-700">14:00 - 17:00</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-lg font-bold text-blue-900">🌧️</div>
|
||||
<div className="text-xs text-blue-700">12°C</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between p-2 bg-yellow-50 rounded-lg border border-yellow-200">
|
||||
<span className="text-sm font-medium text-yellow-900">Impacto estimado</span>
|
||||
<span className="text-sm font-bold text-yellow-900">-15% ventas</span>
|
||||
</div>
|
||||
|
||||
<div className="text-center py-1">
|
||||
<span className="text-xs text-gray-500">Predicciones ya ajustadas</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default QuickOverview;
|
||||
231
frontend/src/components/simple/TodayProduction.tsx
Normal file
231
frontend/src/components/simple/TodayProduction.tsx
Normal file
@@ -0,0 +1,231 @@
|
||||
import React from 'react';
|
||||
import { CheckCircle2, Clock, Loader2, Calendar, Plus, Minus } from 'lucide-react';
|
||||
|
||||
export interface ProductionItem {
|
||||
id: string;
|
||||
product: string;
|
||||
emoji: string;
|
||||
quantity: number;
|
||||
status: 'completed' | 'in_progress' | 'pending';
|
||||
scheduledTime: string;
|
||||
completedTime?: string;
|
||||
confidence?: number;
|
||||
}
|
||||
|
||||
interface TodayProductionProps {
|
||||
items?: ProductionItem[];
|
||||
onUpdateQuantity?: (itemId: string, newQuantity: number) => void;
|
||||
onUpdateStatus?: (itemId: string, status: ProductionItem['status']) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const TodayProduction: React.FC<TodayProductionProps> = ({
|
||||
items = [
|
||||
{
|
||||
id: '1',
|
||||
product: 'Croissants',
|
||||
emoji: '🥐',
|
||||
quantity: 45,
|
||||
status: 'completed',
|
||||
scheduledTime: '06:00',
|
||||
completedTime: '06:30',
|
||||
confidence: 92
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
product: 'Pan integral',
|
||||
emoji: '🍞',
|
||||
quantity: 30,
|
||||
status: 'in_progress',
|
||||
scheduledTime: '07:30',
|
||||
confidence: 87
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
product: 'Magdalenas',
|
||||
emoji: '🧁',
|
||||
quantity: 25,
|
||||
status: 'pending',
|
||||
scheduledTime: '09:00',
|
||||
confidence: 78
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
product: 'Empanadas',
|
||||
emoji: '🥟',
|
||||
quantity: 20,
|
||||
status: 'pending',
|
||||
scheduledTime: '10:30',
|
||||
confidence: 85
|
||||
}
|
||||
],
|
||||
onUpdateQuantity,
|
||||
onUpdateStatus,
|
||||
className = ''
|
||||
}) => {
|
||||
const getStatusIcon = (status: ProductionItem['status']) => {
|
||||
switch (status) {
|
||||
case 'completed': return <CheckCircle2 className="h-5 w-5 text-green-600" />;
|
||||
case 'in_progress': return <Loader2 className="h-5 w-5 text-blue-600 animate-spin" />;
|
||||
case 'pending': return <Clock className="h-5 w-5 text-gray-400" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusText = (status: ProductionItem['status']) => {
|
||||
switch (status) {
|
||||
case 'completed': return 'Listo';
|
||||
case 'in_progress': return 'Haciendo';
|
||||
case 'pending': return 'Pendiente';
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusColors = (status: ProductionItem['status']) => {
|
||||
switch (status) {
|
||||
case 'completed': return 'bg-green-50 border-green-200';
|
||||
case 'in_progress': return 'bg-blue-50 border-blue-200';
|
||||
case 'pending': return 'bg-gray-50 border-gray-200';
|
||||
}
|
||||
};
|
||||
|
||||
const completedCount = items.filter(item => item.status === 'completed').length;
|
||||
const inProgressCount = items.filter(item => item.status === 'in_progress').length;
|
||||
const pendingCount = items.filter(item => item.status === 'pending').length;
|
||||
|
||||
return (
|
||||
<div className={`bg-white rounded-xl shadow-sm border border-gray-200 p-6 ${className}`}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900 flex items-center">
|
||||
<Calendar className="h-5 w-5 mr-2 text-blue-600" />
|
||||
Producir Hoy
|
||||
</h3>
|
||||
<div className="text-xs text-gray-500">
|
||||
{new Date().toLocaleDateString('es-ES', { weekday: 'long', day: 'numeric', month: 'long' })}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress Summary */}
|
||||
<div className="flex items-center space-x-4 mb-4 p-3 bg-gray-50 rounded-lg">
|
||||
<div className="text-center">
|
||||
<div className="text-lg font-bold text-green-600">{completedCount}</div>
|
||||
<div className="text-xs text-gray-600">Listos</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-lg font-bold text-blue-600">{inProgressCount}</div>
|
||||
<div className="text-xs text-gray-600">En curso</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-lg font-bold text-gray-600">{pendingCount}</div>
|
||||
<div className="text-xs text-gray-600">Pendientes</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-lg font-bold text-gray-900">{items.reduce((sum, item) => sum + item.quantity, 0)}</div>
|
||||
<div className="text-xs text-gray-600">Total uds</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Production Items */}
|
||||
<div className="space-y-3">
|
||||
{items.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className={`border rounded-lg p-4 transition-all duration-200 ${getStatusColors(item.status)}`}
|
||||
>
|
||||
<div className="flex items-center space-x-3">
|
||||
{/* Status Icon */}
|
||||
<div className="flex-shrink-0">
|
||||
{getStatusIcon(item.status)}
|
||||
</div>
|
||||
|
||||
{/* Product Info */}
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center space-x-2 mb-1">
|
||||
<span className="text-lg">{item.emoji}</span>
|
||||
<h4 className="font-medium text-gray-900">{item.product}</h4>
|
||||
{item.confidence && (
|
||||
<span className="text-xs text-gray-500">
|
||||
({item.confidence}% confianza)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-4 text-sm text-gray-600">
|
||||
<span className="flex items-center">
|
||||
<Clock className="h-3 w-3 mr-1" />
|
||||
{item.status === 'completed' && item.completedTime
|
||||
? `Listo a las ${item.completedTime}`
|
||||
: `Programado ${item.scheduledTime}`}
|
||||
</span>
|
||||
<span className={`px-2 py-1 rounded text-xs font-medium ${
|
||||
item.status === 'completed' ? 'bg-green-100 text-green-800' :
|
||||
item.status === 'in_progress' ? 'bg-blue-100 text-blue-800' :
|
||||
'bg-gray-100 text-gray-800'
|
||||
}`}>
|
||||
{getStatusText(item.status)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quantity Controls */}
|
||||
<div className="flex-shrink-0">
|
||||
<div className="flex items-center space-x-2">
|
||||
<button
|
||||
onClick={() => onUpdateQuantity?.(item.id, Math.max(1, item.quantity - 1))}
|
||||
disabled={item.status === 'completed'}
|
||||
className="p-1 rounded hover:bg-gray-200 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Minus className="h-3 w-3" />
|
||||
</button>
|
||||
|
||||
<span className="text-lg font-bold text-gray-900 min-w-[40px] text-center">
|
||||
{item.quantity}
|
||||
</span>
|
||||
|
||||
<button
|
||||
onClick={() => onUpdateQuantity?.(item.id, item.quantity + 1)}
|
||||
disabled={item.status === 'completed'}
|
||||
className="p-1 rounded hover:bg-gray-200 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 text-center mt-1">uds</div>
|
||||
</div>
|
||||
|
||||
{/* Action Button */}
|
||||
{item.status !== 'completed' && (
|
||||
<div className="flex-shrink-0">
|
||||
<button
|
||||
onClick={() => {
|
||||
const nextStatus = item.status === 'pending' ? 'in_progress' : 'completed';
|
||||
onUpdateStatus?.(item.id, nextStatus);
|
||||
}}
|
||||
className={`px-3 py-1 rounded text-xs font-medium transition-colors ${
|
||||
item.status === 'pending'
|
||||
? 'bg-blue-600 text-white hover:bg-blue-700'
|
||||
: 'bg-green-600 text-white hover:bg-green-700'
|
||||
}`}
|
||||
>
|
||||
{item.status === 'pending' ? 'Iniciar' : 'Completar'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<div className="flex items-center justify-between pt-4 mt-4 border-t border-gray-100">
|
||||
<button className="text-sm text-gray-600 hover:text-gray-900 font-medium">
|
||||
+ Agregar producto
|
||||
</button>
|
||||
<button className="text-sm text-blue-600 hover:text-blue-700 font-medium">
|
||||
Ver horario completo →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TodayProduction;
|
||||
95
frontend/src/components/simple/TodayRevenue.tsx
Normal file
95
frontend/src/components/simple/TodayRevenue.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
import React from 'react';
|
||||
import { TrendingUp, TrendingDown, Euro } from 'lucide-react';
|
||||
|
||||
interface TodayRevenueProps {
|
||||
currentRevenue: number;
|
||||
previousRevenue: number;
|
||||
dailyTarget: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const TodayRevenue: React.FC<TodayRevenueProps> = ({
|
||||
currentRevenue = 287.50,
|
||||
previousRevenue = 256.25,
|
||||
dailyTarget = 350,
|
||||
className = ''
|
||||
}) => {
|
||||
const changeAmount = currentRevenue - previousRevenue;
|
||||
const changePercentage = ((changeAmount / previousRevenue) * 100);
|
||||
const remainingToTarget = Math.max(0, dailyTarget - currentRevenue);
|
||||
const targetProgress = (currentRevenue / dailyTarget) * 100;
|
||||
const isPositive = changeAmount >= 0;
|
||||
|
||||
return (
|
||||
<div className={`bg-white rounded-xl shadow-sm border border-gray-200 p-6 ${className}`}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900 flex items-center">
|
||||
<Euro className="h-5 w-5 mr-2 text-green-600" />
|
||||
Ingresos de Hoy
|
||||
</h3>
|
||||
<span className="text-xs text-gray-500">
|
||||
{new Date().toLocaleDateString('es-ES')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Main Revenue */}
|
||||
<div className="mb-4">
|
||||
<div className="flex items-baseline space-x-2">
|
||||
<span className="text-3xl font-bold text-gray-900">
|
||||
€{currentRevenue.toFixed(2)}
|
||||
</span>
|
||||
<div className={`flex items-center text-sm font-medium ${
|
||||
isPositive ? 'text-green-600' : 'text-red-600'
|
||||
}`}>
|
||||
{isPositive ? (
|
||||
<TrendingUp className="h-4 w-4 mr-1" />
|
||||
) : (
|
||||
<TrendingDown className="h-4 w-4 mr-1" />
|
||||
)}
|
||||
{isPositive ? '+' : ''}{changePercentage.toFixed(1)}%
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 mt-1">
|
||||
{isPositive ? '+' : ''} €{changeAmount.toFixed(2)} vs ayer
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Target Progress */}
|
||||
<div className="mb-4">
|
||||
<div className="flex items-center justify-between text-sm text-gray-600 mb-2">
|
||||
<span>Meta del día: €{dailyTarget}</span>
|
||||
<span>{targetProgress.toFixed(1)}%</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 rounded-full h-2">
|
||||
<div
|
||||
className={`h-2 rounded-full transition-all duration-300 ${
|
||||
targetProgress >= 100 ? 'bg-green-500' :
|
||||
targetProgress >= 80 ? 'bg-yellow-500' : 'bg-blue-500'
|
||||
}`}
|
||||
style={{ width: `${Math.min(targetProgress, 100)}%` }}
|
||||
></div>
|
||||
</div>
|
||||
{remainingToTarget > 0 && (
|
||||
<p className="text-sm text-gray-600 mt-2">
|
||||
Faltan €{remainingToTarget.toFixed(2)} para la meta
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Quick Stats */}
|
||||
<div className="grid grid-cols-2 gap-3 pt-4 border-t border-gray-100">
|
||||
<div className="text-center">
|
||||
<p className="text-xs text-gray-500">Esta Semana</p>
|
||||
<p className="text-lg font-semibold text-gray-900">€1,940</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-xs text-gray-500">Promedio/Día</p>
|
||||
<p className="text-lg font-semibold text-gray-900">€{(1940/7).toFixed(0)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TodayRevenue;
|
||||
438
frontend/src/components/ui/AIInsightsFeed.tsx
Normal file
438
frontend/src/components/ui/AIInsightsFeed.tsx
Normal file
@@ -0,0 +1,438 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Brain, TrendingUp, TrendingDown, Star, Clock, Eye, EyeOff, MoreHorizontal } from 'lucide-react';
|
||||
|
||||
interface AIInsight {
|
||||
id: string;
|
||||
type: 'trend' | 'opportunity' | 'warning' | 'recommendation' | 'achievement';
|
||||
title: string;
|
||||
description: string;
|
||||
confidence: number;
|
||||
impact: 'high' | 'medium' | 'low';
|
||||
category: 'demand' | 'revenue' | 'efficiency' | 'quality' | 'customer';
|
||||
timestamp: string;
|
||||
data?: {
|
||||
trend?: {
|
||||
direction: 'up' | 'down';
|
||||
percentage: number;
|
||||
period: string;
|
||||
};
|
||||
revenue?: {
|
||||
amount: number;
|
||||
comparison: string;
|
||||
};
|
||||
actionable?: {
|
||||
action: string;
|
||||
expectedImpact: string;
|
||||
};
|
||||
};
|
||||
isRead?: boolean;
|
||||
isStarred?: boolean;
|
||||
}
|
||||
|
||||
interface AIInsightsFeedProps {
|
||||
insights?: AIInsight[];
|
||||
onInsightAction?: (insightId: string, action: 'read' | 'star' | 'dismiss') => void;
|
||||
maxItems?: number;
|
||||
showFilters?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const AIInsightsFeed: React.FC<AIInsightsFeedProps> = ({
|
||||
insights: propInsights,
|
||||
onInsightAction,
|
||||
maxItems = 10,
|
||||
showFilters = true,
|
||||
className = ''
|
||||
}) => {
|
||||
const [insights, setInsights] = useState<AIInsight[]>([]);
|
||||
const [selectedCategory, setSelectedCategory] = useState<string>('all');
|
||||
const [selectedImpact, setSelectedImpact] = useState<string>('all');
|
||||
const [showOnlyUnread, setShowOnlyUnread] = useState(false);
|
||||
|
||||
// Generate realistic AI insights if none provided
|
||||
useEffect(() => {
|
||||
if (propInsights) {
|
||||
setInsights(propInsights);
|
||||
} else {
|
||||
setInsights(generateSampleInsights());
|
||||
}
|
||||
}, [propInsights]);
|
||||
|
||||
const generateSampleInsights = (): AIInsight[] => {
|
||||
const now = new Date();
|
||||
const yesterday = new Date(now.getTime() - 24 * 60 * 60 * 1000);
|
||||
const twoDaysAgo = new Date(now.getTime() - 2 * 24 * 60 * 60 * 1000);
|
||||
|
||||
return [
|
||||
{
|
||||
id: '1',
|
||||
type: 'opportunity',
|
||||
title: 'Oportunidad: Incremento en demanda de tartas',
|
||||
description: 'Los datos muestran un aumento del 23% en la demanda de tartas los viernes. Considera aumentar la producción para maximizar ingresos.',
|
||||
confidence: 87,
|
||||
impact: 'high',
|
||||
category: 'revenue',
|
||||
timestamp: now.toISOString(),
|
||||
data: {
|
||||
trend: { direction: 'up', percentage: 23, period: 'últimas 3 semanas' },
|
||||
actionable: { action: 'Aumentar producción de tartas los viernes', expectedImpact: '+€180/semana' }
|
||||
},
|
||||
isRead: false,
|
||||
isStarred: false
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
type: 'warning',
|
||||
title: 'Alerta: Posible desperdicio de magdalenas',
|
||||
description: 'Las magdalenas tienen una tasa de venta del 67% los martes. Reducir la producción podría ahorrar €45 semanales.',
|
||||
confidence: 78,
|
||||
impact: 'medium',
|
||||
category: 'efficiency',
|
||||
timestamp: yesterday.toISOString(),
|
||||
data: {
|
||||
revenue: { amount: 45, comparison: 'ahorro semanal estimado' },
|
||||
actionable: { action: 'Reducir producción de magdalenas los martes', expectedImpact: '-€45 desperdicio' }
|
||||
},
|
||||
isRead: true,
|
||||
isStarred: false
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
type: 'trend',
|
||||
title: 'Tendencia: Croissants más populares por las mañanas',
|
||||
description: 'El 78% de las ventas de croissants ocurren antes de las 11 AM. Considera reorganizar la producción matutina.',
|
||||
confidence: 92,
|
||||
impact: 'medium',
|
||||
category: 'efficiency',
|
||||
timestamp: yesterday.toISOString(),
|
||||
data: {
|
||||
trend: { direction: 'up', percentage: 78, period: 'horario matutino' },
|
||||
actionable: { action: 'Priorizar croissants en producción matutina', expectedImpact: 'Mejor disponibilidad' }
|
||||
},
|
||||
isRead: false,
|
||||
isStarred: true
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
type: 'achievement',
|
||||
title: '¡Éxito! Predicciones de ayer fueron 94% precisas',
|
||||
description: 'Las predicciones de demanda de ayer tuvieron una precisión del 94%, resultando en ventas óptimas sin desperdicios.',
|
||||
confidence: 94,
|
||||
impact: 'high',
|
||||
category: 'quality',
|
||||
timestamp: yesterday.toISOString(),
|
||||
data: {
|
||||
revenue: { amount: 127, comparison: 'ingresos adicionales por precisión' }
|
||||
},
|
||||
isRead: false,
|
||||
isStarred: false
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
type: 'recommendation',
|
||||
title: 'Recomendación: Nuevo producto para fin de semana',
|
||||
description: 'Los datos de fin de semana sugieren que los clientes buscan productos especiales. Una tarta de temporada podría generar €200 adicionales.',
|
||||
confidence: 72,
|
||||
impact: 'high',
|
||||
category: 'revenue',
|
||||
timestamp: twoDaysAgo.toISOString(),
|
||||
data: {
|
||||
revenue: { amount: 200, comparison: 'ingresos potenciales fin de semana' },
|
||||
actionable: { action: 'Introducir tarta especial de fin de semana', expectedImpact: '+€200/semana' }
|
||||
},
|
||||
isRead: true,
|
||||
isStarred: false
|
||||
},
|
||||
{
|
||||
id: '6',
|
||||
type: 'trend',
|
||||
title: 'Patrón meteorológico: Lluvia afecta ventas -15%',
|
||||
description: 'Los días lluviosos reducen las ventas en promedio 15%. El algoritmo ahora ajusta automáticamente las predicciones.',
|
||||
confidence: 88,
|
||||
impact: 'medium',
|
||||
category: 'demand',
|
||||
timestamp: twoDaysAgo.toISOString(),
|
||||
data: {
|
||||
trend: { direction: 'down', percentage: 15, period: 'días lluviosos' },
|
||||
actionable: { action: 'Producción automáticamente ajustada', expectedImpact: 'Menos desperdicio' }
|
||||
},
|
||||
isRead: true,
|
||||
isStarred: false
|
||||
}
|
||||
];
|
||||
};
|
||||
|
||||
const handleInsightAction = (insightId: string, action: 'read' | 'star' | 'dismiss') => {
|
||||
setInsights(prev => prev.map(insight => {
|
||||
if (insight.id === insightId) {
|
||||
switch (action) {
|
||||
case 'read':
|
||||
return { ...insight, isRead: true };
|
||||
case 'star':
|
||||
return { ...insight, isStarred: !insight.isStarred };
|
||||
case 'dismiss':
|
||||
return insight; // In a real app, this might remove the insight
|
||||
}
|
||||
}
|
||||
return insight;
|
||||
}));
|
||||
|
||||
onInsightAction?.(insightId, action);
|
||||
};
|
||||
|
||||
const filteredInsights = insights.filter(insight => {
|
||||
if (selectedCategory !== 'all' && insight.category !== selectedCategory) return false;
|
||||
if (selectedImpact !== 'all' && insight.impact !== selectedImpact) return false;
|
||||
if (showOnlyUnread && insight.isRead) return false;
|
||||
return true;
|
||||
}).slice(0, maxItems);
|
||||
|
||||
const getInsightIcon = (type: AIInsight['type']) => {
|
||||
switch (type) {
|
||||
case 'trend': return TrendingUp;
|
||||
case 'opportunity': return Star;
|
||||
case 'warning': return TrendingDown;
|
||||
case 'recommendation': return Brain;
|
||||
case 'achievement': return Star;
|
||||
default: return Brain;
|
||||
}
|
||||
};
|
||||
|
||||
const getInsightColors = (type: AIInsight['type'], impact: AIInsight['impact']) => {
|
||||
const baseColors = {
|
||||
trend: 'blue',
|
||||
opportunity: 'green',
|
||||
warning: 'yellow',
|
||||
recommendation: 'purple',
|
||||
achievement: 'emerald'
|
||||
};
|
||||
|
||||
const color = baseColors[type] || 'gray';
|
||||
const intensity = impact === 'high' ? '600' : impact === 'medium' ? '500' : '400';
|
||||
|
||||
return {
|
||||
background: `bg-${color}-50`,
|
||||
border: `border-${color}-200`,
|
||||
icon: `text-${color}-${intensity}`,
|
||||
badge: impact === 'high' ? `bg-${color}-100 text-${color}-800` :
|
||||
impact === 'medium' ? `bg-${color}-50 text-${color}-700` :
|
||||
`bg-gray-100 text-gray-600`
|
||||
};
|
||||
};
|
||||
|
||||
const formatTimestamp = (timestamp: string) => {
|
||||
const date = new Date(timestamp);
|
||||
const now = new Date();
|
||||
const diffHours = Math.floor((now.getTime() - date.getTime()) / (1000 * 60 * 60));
|
||||
|
||||
if (diffHours < 1) return 'Hace unos minutos';
|
||||
if (diffHours < 24) return `Hace ${diffHours}h`;
|
||||
if (diffHours < 48) return 'Ayer';
|
||||
return date.toLocaleDateString('es-ES', { day: 'numeric', month: 'short' });
|
||||
};
|
||||
|
||||
const unreadCount = insights.filter(i => !i.isRead).length;
|
||||
|
||||
return (
|
||||
<div className={`bg-white rounded-xl shadow-soft ${className}`}>
|
||||
{/* Header */}
|
||||
<div className="p-6 border-b border-gray-200">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
<Brain className="h-6 w-6 text-purple-600 mr-3" />
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">
|
||||
Insights de IA
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600">
|
||||
Recomendaciones inteligentes para tu negocio
|
||||
{unreadCount > 0 && (
|
||||
<span className="ml-2 px-2 py-1 bg-purple-100 text-purple-800 text-xs rounded-full">
|
||||
{unreadCount} nuevos
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
{showFilters && (
|
||||
<div className="mt-4 flex flex-wrap gap-3">
|
||||
<select
|
||||
value={selectedCategory}
|
||||
onChange={(e) => setSelectedCategory(e.target.value)}
|
||||
className="text-sm border border-gray-300 rounded-lg px-3 py-1.5 focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||
>
|
||||
<option value="all">Todas las categorías</option>
|
||||
<option value="demand">Demanda</option>
|
||||
<option value="revenue">Ingresos</option>
|
||||
<option value="efficiency">Eficiencia</option>
|
||||
<option value="quality">Calidad</option>
|
||||
<option value="customer">Cliente</option>
|
||||
</select>
|
||||
|
||||
<select
|
||||
value={selectedImpact}
|
||||
onChange={(e) => setSelectedImpact(e.target.value)}
|
||||
className="text-sm border border-gray-300 rounded-lg px-3 py-1.5 focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||
>
|
||||
<option value="all">Todos los impactos</option>
|
||||
<option value="high">Alto impacto</option>
|
||||
<option value="medium">Impacto medio</option>
|
||||
<option value="low">Bajo impacto</option>
|
||||
</select>
|
||||
|
||||
<label className="flex items-center text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showOnlyUnread}
|
||||
onChange={(e) => setShowOnlyUnread(e.target.checked)}
|
||||
className="h-4 w-4 text-purple-600 focus:ring-purple-500 border-gray-300 rounded mr-2"
|
||||
/>
|
||||
Solo no leídos
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Insights List */}
|
||||
<div className="divide-y divide-gray-100 max-h-96 overflow-y-auto">
|
||||
{filteredInsights.map((insight) => {
|
||||
const IconComponent = getInsightIcon(insight.type);
|
||||
const colors = getInsightColors(insight.type, insight.impact);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={insight.id}
|
||||
className={`p-4 hover:bg-gray-50 transition-colors ${!insight.isRead ? 'bg-purple-25' : ''}`}
|
||||
>
|
||||
<div className="flex items-start space-x-3">
|
||||
{/* Icon */}
|
||||
<div className={`flex-shrink-0 p-2 rounded-lg ${colors.background}`}>
|
||||
<IconComponent className={`h-4 w-4 ${colors.icon}`} />
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<h4 className={`text-sm font-medium ${!insight.isRead ? 'text-gray-900' : 'text-gray-700'}`}>
|
||||
{insight.title}
|
||||
</h4>
|
||||
<p className="text-sm text-gray-600 mt-1 line-clamp-2">
|
||||
{insight.description}
|
||||
</p>
|
||||
|
||||
{/* Metadata */}
|
||||
<div className="flex items-center mt-2 space-x-3">
|
||||
<span className="text-xs text-gray-500 flex items-center">
|
||||
<Clock className="h-3 w-3 mr-1" />
|
||||
{formatTimestamp(insight.timestamp)}
|
||||
</span>
|
||||
|
||||
<span className={`px-2 py-1 text-xs rounded-full ${colors.badge}`}>
|
||||
{insight.impact === 'high' ? 'Alto' : insight.impact === 'medium' ? 'Medio' : 'Bajo'} impacto
|
||||
</span>
|
||||
|
||||
<span className="text-xs text-gray-500">
|
||||
{insight.confidence}% confianza
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Data Insights */}
|
||||
{insight.data && (
|
||||
<div className="mt-3 p-3 bg-gray-50 rounded-lg">
|
||||
{insight.data.trend && (
|
||||
<div className="flex items-center text-sm">
|
||||
{insight.data.trend.direction === 'up' ? (
|
||||
<TrendingUp className="h-4 w-4 text-green-600 mr-2" />
|
||||
) : (
|
||||
<TrendingDown className="h-4 w-4 text-red-600 mr-2" />
|
||||
)}
|
||||
<span className="font-medium">
|
||||
{insight.data.trend.percentage}% {insight.data.trend.direction === 'up' ? 'aumento' : 'reducción'}
|
||||
</span>
|
||||
<span className="text-gray-600 ml-1">
|
||||
en {insight.data.trend.period}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{insight.data.revenue && (
|
||||
<div className="flex items-center text-sm">
|
||||
<span className="font-medium text-green-600">€{insight.data.revenue.amount}</span>
|
||||
<span className="text-gray-600 ml-1">{insight.data.revenue.comparison}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{insight.data.actionable && (
|
||||
<div className="mt-2 text-sm">
|
||||
<div className="font-medium text-gray-900">{insight.data.actionable.action}</div>
|
||||
<div className="text-green-600">{insight.data.actionable.expectedImpact}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center space-x-1 ml-4">
|
||||
<button
|
||||
onClick={() => handleInsightAction(insight.id, 'star')}
|
||||
className={`p-1.5 rounded-lg hover:bg-gray-100 transition-colors ${
|
||||
insight.isStarred ? 'text-yellow-600' : 'text-gray-400 hover:text-gray-600'
|
||||
}`}
|
||||
>
|
||||
<Star className={`h-4 w-4 ${insight.isStarred ? 'fill-current' : ''}`} />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => handleInsightAction(insight.id, 'read')}
|
||||
className={`p-1.5 rounded-lg hover:bg-gray-100 transition-colors ${
|
||||
insight.isRead ? 'text-gray-400' : 'text-blue-600 hover:text-blue-700'
|
||||
}`}
|
||||
>
|
||||
{insight.isRead ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</button>
|
||||
|
||||
<button className="p-1.5 rounded-lg hover:bg-gray-100 text-gray-400 hover:text-gray-600 transition-colors">
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{filteredInsights.length === 0 && (
|
||||
<div className="p-8 text-center">
|
||||
<Brain className="h-12 w-12 text-gray-300 mx-auto mb-4" />
|
||||
<p className="text-gray-500">No hay insights disponibles</p>
|
||||
<p className="text-sm text-gray-400 mt-1">
|
||||
Los insights aparecerán aquí conforme el sistema analice tus datos
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
{filteredInsights.length > 0 && (
|
||||
<div className="p-4 border-t border-gray-200 bg-gray-50">
|
||||
<div className="text-center">
|
||||
<p className="text-xs text-gray-500">
|
||||
Mostrando {filteredInsights.length} de {insights.length} insights
|
||||
</p>
|
||||
<button className="text-xs text-purple-600 hover:text-purple-700 mt-1 font-medium">
|
||||
Ver historial completo
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AIInsightsFeed;
|
||||
155
frontend/src/components/ui/AlertCard.tsx
Normal file
155
frontend/src/components/ui/AlertCard.tsx
Normal file
@@ -0,0 +1,155 @@
|
||||
import React from 'react';
|
||||
import { AlertTriangle, TrendingDown, Package, Clock, Euro } from 'lucide-react';
|
||||
|
||||
export interface BusinessAlert {
|
||||
id: string;
|
||||
type: 'stockout_risk' | 'overstock' | 'revenue_loss' | 'quality_risk' | 'weather_impact';
|
||||
severity: 'low' | 'medium' | 'high' | 'critical';
|
||||
product: string;
|
||||
message: string;
|
||||
action: string;
|
||||
impact?: {
|
||||
type: 'revenue' | 'units' | 'percentage';
|
||||
value: number;
|
||||
currency?: string;
|
||||
};
|
||||
urgency?: 'immediate' | 'today' | 'this_week';
|
||||
}
|
||||
|
||||
interface AlertCardProps {
|
||||
alert: BusinessAlert;
|
||||
onAction?: (alertId: string, actionType: string) => void;
|
||||
}
|
||||
|
||||
const getSeverityConfig = (severity: BusinessAlert['severity']) => {
|
||||
switch (severity) {
|
||||
case 'critical':
|
||||
return {
|
||||
bgColor: 'bg-red-50',
|
||||
borderColor: 'border-red-200',
|
||||
iconColor: 'text-red-600',
|
||||
textColor: 'text-red-900',
|
||||
actionColor: 'bg-red-100 hover:bg-red-200 text-red-800'
|
||||
};
|
||||
case 'high':
|
||||
return {
|
||||
bgColor: 'bg-orange-50',
|
||||
borderColor: 'border-orange-200',
|
||||
iconColor: 'text-orange-600',
|
||||
textColor: 'text-orange-900',
|
||||
actionColor: 'bg-orange-100 hover:bg-orange-200 text-orange-800'
|
||||
};
|
||||
case 'medium':
|
||||
return {
|
||||
bgColor: 'bg-yellow-50',
|
||||
borderColor: 'border-yellow-200',
|
||||
iconColor: 'text-yellow-600',
|
||||
textColor: 'text-yellow-900',
|
||||
actionColor: 'bg-yellow-100 hover:bg-yellow-200 text-yellow-800'
|
||||
};
|
||||
default:
|
||||
return {
|
||||
bgColor: 'bg-blue-50',
|
||||
borderColor: 'border-blue-200',
|
||||
iconColor: 'text-blue-600',
|
||||
textColor: 'text-blue-900',
|
||||
actionColor: 'bg-blue-100 hover:bg-blue-200 text-blue-800'
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const getAlertIcon = (type: BusinessAlert['type']) => {
|
||||
switch (type) {
|
||||
case 'stockout_risk':
|
||||
return Package;
|
||||
case 'overstock':
|
||||
return TrendingDown;
|
||||
case 'revenue_loss':
|
||||
return Euro;
|
||||
case 'quality_risk':
|
||||
return Clock;
|
||||
case 'weather_impact':
|
||||
return AlertTriangle;
|
||||
default:
|
||||
return AlertTriangle;
|
||||
}
|
||||
};
|
||||
|
||||
const getUrgencyLabel = (urgency?: BusinessAlert['urgency']) => {
|
||||
switch (urgency) {
|
||||
case 'immediate':
|
||||
return { label: 'URGENTE', color: 'bg-red-100 text-red-800' };
|
||||
case 'today':
|
||||
return { label: 'HOY', color: 'bg-orange-100 text-orange-800' };
|
||||
case 'this_week':
|
||||
return { label: 'ESTA SEMANA', color: 'bg-blue-100 text-blue-800' };
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const AlertCard: React.FC<AlertCardProps> = ({ alert, onAction }) => {
|
||||
const config = getSeverityConfig(alert.severity);
|
||||
const Icon = getAlertIcon(alert.type);
|
||||
const urgencyInfo = getUrgencyLabel(alert.urgency);
|
||||
|
||||
const handleAction = () => {
|
||||
onAction?.(alert.id, 'primary_action');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`${config.bgColor} ${config.borderColor} border rounded-lg p-4 shadow-sm`}>
|
||||
<div className="flex items-start space-x-3">
|
||||
<div className={`${config.iconColor} mt-0.5`}>
|
||||
<Icon className="h-5 w-5" />
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center space-x-2">
|
||||
<h4 className={`font-medium ${config.textColor}`}>
|
||||
{alert.product}
|
||||
</h4>
|
||||
{urgencyInfo && (
|
||||
<span className={`px-2 py-1 text-xs font-bold rounded ${urgencyInfo.color}`}>
|
||||
{urgencyInfo.label}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className={`text-sm mt-1 ${config.textColor.replace('900', '700')}`}>
|
||||
{alert.message}
|
||||
</p>
|
||||
|
||||
{alert.impact && (
|
||||
<div className={`text-sm font-medium mt-2 ${config.textColor}`}>
|
||||
{alert.impact.type === 'revenue' && (
|
||||
<>Impacto: -{alert.impact.value}{alert.impact.currency || '€'}</>
|
||||
)}
|
||||
{alert.impact.type === 'units' && (
|
||||
<>Unidades afectadas: {alert.impact.value}</>
|
||||
)}
|
||||
{alert.impact.type === 'percentage' && (
|
||||
<>Reducción estimada: {alert.impact.value}%</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3">
|
||||
<button
|
||||
onClick={handleAction}
|
||||
className={`px-3 py-2 text-sm font-medium rounded transition-colors ${config.actionColor}`}
|
||||
>
|
||||
{alert.action}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AlertCard;
|
||||
408
frontend/src/components/ui/CompetitiveBenchmarks.tsx
Normal file
408
frontend/src/components/ui/CompetitiveBenchmarks.tsx
Normal file
@@ -0,0 +1,408 @@
|
||||
import { useState } from 'react';
|
||||
import { BarChart3, TrendingUp, TrendingDown, Award, Target, Eye, EyeOff } from 'lucide-react';
|
||||
|
||||
interface BenchmarkMetric {
|
||||
id: string;
|
||||
name: string;
|
||||
yourValue: number;
|
||||
industryAverage: number;
|
||||
topPerformers: number;
|
||||
unit: string;
|
||||
description: string;
|
||||
category: 'efficiency' | 'revenue' | 'waste' | 'customer' | 'quality';
|
||||
trend: 'improving' | 'declining' | 'stable';
|
||||
percentile: number; // Your position (0-100)
|
||||
insights: string[];
|
||||
}
|
||||
|
||||
interface CompetitiveBenchmarksProps {
|
||||
metrics?: BenchmarkMetric[];
|
||||
location?: string; // e.g., "Madrid Centro"
|
||||
showSensitiveData?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const CompetitiveBenchmarks: React.FC<CompetitiveBenchmarksProps> = ({
|
||||
metrics: propMetrics,
|
||||
location = "Madrid Centro",
|
||||
showSensitiveData = true,
|
||||
className = ''
|
||||
}) => {
|
||||
const [selectedCategory, setSelectedCategory] = useState<string>('all');
|
||||
const [showDetails, setShowDetails] = useState<boolean>(showSensitiveData);
|
||||
|
||||
// Sample benchmark data (anonymized)
|
||||
const defaultMetrics: BenchmarkMetric[] = [
|
||||
{
|
||||
id: 'forecast_accuracy',
|
||||
name: 'Precisión de Predicciones',
|
||||
yourValue: 87.2,
|
||||
industryAverage: 72.5,
|
||||
topPerformers: 94.1,
|
||||
unit: '%',
|
||||
description: 'Qué tan precisas son las predicciones vs. ventas reales',
|
||||
category: 'quality',
|
||||
trend: 'improving',
|
||||
percentile: 85,
|
||||
insights: [
|
||||
'Superioridad del 15% vs. promedio de la industria',
|
||||
'Solo 7 puntos por debajo de los mejores del sector',
|
||||
'Mejora consistente en los últimos 3 meses'
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'waste_percentage',
|
||||
name: 'Porcentaje de Desperdicio',
|
||||
yourValue: 8.3,
|
||||
industryAverage: 12.7,
|
||||
topPerformers: 4.2,
|
||||
unit: '%',
|
||||
description: 'Productos no vendidos como % del total producido',
|
||||
category: 'waste',
|
||||
trend: 'improving',
|
||||
percentile: 78,
|
||||
insights: [
|
||||
'35% menos desperdicio que el promedio',
|
||||
'Oportunidad: reducir 4 puntos más para llegar al top',
|
||||
'Ahorro de ~€230/mes vs. promedio de la industria'
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'revenue_per_sqm',
|
||||
name: 'Ingresos por m²',
|
||||
yourValue: 2847,
|
||||
industryAverage: 2134,
|
||||
topPerformers: 4521,
|
||||
unit: '€/mes',
|
||||
description: 'Ingresos mensuales por metro cuadrado de local',
|
||||
category: 'revenue',
|
||||
trend: 'stable',
|
||||
percentile: 73,
|
||||
insights: [
|
||||
'33% más eficiente en generación de ingresos',
|
||||
'Potencial de crecimiento: +59% para alcanzar el top',
|
||||
'Excelente aprovechamiento del espacio'
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'customer_retention',
|
||||
name: 'Retención de Clientes',
|
||||
yourValue: 68,
|
||||
industryAverage: 61,
|
||||
topPerformers: 84,
|
||||
unit: '%',
|
||||
description: 'Clientes que regresan al menos una vez por semana',
|
||||
category: 'customer',
|
||||
trend: 'improving',
|
||||
percentile: 67,
|
||||
insights: [
|
||||
'11% mejor retención que la competencia',
|
||||
'Oportunidad: programas de fidelización podrían sumar 16 puntos',
|
||||
'Base de clientes sólida y leal'
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'production_efficiency',
|
||||
name: 'Eficiencia de Producción',
|
||||
yourValue: 1.8,
|
||||
industryAverage: 2.3,
|
||||
topPerformers: 1.2,
|
||||
unit: 'h/100 unidades',
|
||||
description: 'Tiempo promedio para producir 100 unidades',
|
||||
category: 'efficiency',
|
||||
trend: 'improving',
|
||||
percentile: 71,
|
||||
insights: [
|
||||
'22% más rápido que el promedio',
|
||||
'Excelente optimización de procesos',
|
||||
'Margen para mejora: -33% para ser top performer'
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'profit_margin',
|
||||
name: 'Margen de Ganancia',
|
||||
yourValue: 32.5,
|
||||
industryAverage: 28.1,
|
||||
topPerformers: 41.7,
|
||||
unit: '%',
|
||||
description: 'Ganancia neta como % de los ingresos totales',
|
||||
category: 'revenue',
|
||||
trend: 'stable',
|
||||
percentile: 69,
|
||||
insights: [
|
||||
'16% más rentable que la competencia',
|
||||
'Sólida gestión de costos',
|
||||
'Oportunidad: optimizar ingredientes premium'
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
const metrics = propMetrics || defaultMetrics;
|
||||
|
||||
const categories = [
|
||||
{ id: 'all', name: 'Todas', count: metrics.length },
|
||||
{ id: 'revenue', name: 'Ingresos', count: metrics.filter(m => m.category === 'revenue').length },
|
||||
{ id: 'efficiency', name: 'Eficiencia', count: metrics.filter(m => m.category === 'efficiency').length },
|
||||
{ id: 'waste', name: 'Desperdicio', count: metrics.filter(m => m.category === 'waste').length },
|
||||
{ id: 'customer', name: 'Clientes', count: metrics.filter(m => m.category === 'customer').length },
|
||||
{ id: 'quality', name: 'Calidad', count: metrics.filter(m => m.category === 'quality').length }
|
||||
];
|
||||
|
||||
const filteredMetrics = metrics.filter(metric =>
|
||||
selectedCategory === 'all' || metric.category === selectedCategory
|
||||
);
|
||||
|
||||
const getPerformanceLevel = (percentile: number) => {
|
||||
if (percentile >= 90) return { label: 'Excelente', color: 'text-green-600', bg: 'bg-green-50' };
|
||||
if (percentile >= 75) return { label: 'Bueno', color: 'text-blue-600', bg: 'bg-blue-50' };
|
||||
if (percentile >= 50) return { label: 'Promedio', color: 'text-yellow-600', bg: 'bg-yellow-50' };
|
||||
return { label: 'Mejora Necesaria', color: 'text-red-600', bg: 'bg-red-50' };
|
||||
};
|
||||
|
||||
const getTrendIcon = (trend: string) => {
|
||||
switch (trend) {
|
||||
case 'improving': return <TrendingUp className="h-4 w-4 text-green-600" />;
|
||||
case 'declining': return <TrendingDown className="h-4 w-4 text-red-600" />;
|
||||
default: return <div className="w-4 h-4 bg-gray-400 rounded-full"></div>;
|
||||
}
|
||||
};
|
||||
|
||||
const getComparisonPercentage = (yourValue: number, compareValue: number, isLowerBetter = false) => {
|
||||
const diff = isLowerBetter
|
||||
? ((compareValue - yourValue) / compareValue) * 100
|
||||
: ((yourValue - compareValue) / compareValue) * 100;
|
||||
return {
|
||||
value: Math.abs(diff),
|
||||
isPositive: diff > 0
|
||||
};
|
||||
};
|
||||
|
||||
const isLowerBetter = (metricId: string) => {
|
||||
return ['waste_percentage', 'production_efficiency'].includes(metricId);
|
||||
};
|
||||
|
||||
const averagePercentile = Math.round(metrics.reduce((sum, m) => sum + m.percentile, 0) / metrics.length);
|
||||
|
||||
return (
|
||||
<div className={`bg-white rounded-xl shadow-soft ${className}`}>
|
||||
{/* Header */}
|
||||
<div className="p-6 border-b border-gray-200">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
<BarChart3 className="h-6 w-6 text-indigo-600 mr-3" />
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">
|
||||
Benchmarks Competitivos
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600">
|
||||
Comparación anónima con panaderías similares en {location}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-3">
|
||||
{/* Overall Score */}
|
||||
<div className="text-right">
|
||||
<div className="text-2xl font-bold text-indigo-600">
|
||||
{averagePercentile}
|
||||
</div>
|
||||
<div className="text-xs text-gray-600">Percentil General</div>
|
||||
</div>
|
||||
|
||||
{/* Toggle Details */}
|
||||
<button
|
||||
onClick={() => setShowDetails(!showDetails)}
|
||||
className="p-2 rounded-lg hover:bg-gray-100 text-gray-600"
|
||||
title={showDetails ? "Ocultar detalles" : "Mostrar detalles"}
|
||||
>
|
||||
{showDetails ? <EyeOff className="h-5 w-5" /> : <Eye className="h-5 w-5" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Performance Summary */}
|
||||
<div className="mt-4 grid grid-cols-3 gap-4">
|
||||
<div className="bg-green-50 rounded-lg p-3 text-center">
|
||||
<div className="text-lg font-bold text-green-600">
|
||||
{metrics.filter(m => m.percentile >= 75).length}
|
||||
</div>
|
||||
<div className="text-xs text-green-700">Métricas Top 25%</div>
|
||||
</div>
|
||||
<div className="bg-blue-50 rounded-lg p-3 text-center">
|
||||
<div className="text-lg font-bold text-blue-600">
|
||||
{metrics.filter(m => m.trend === 'improving').length}
|
||||
</div>
|
||||
<div className="text-xs text-blue-700">En Mejora</div>
|
||||
</div>
|
||||
<div className="bg-yellow-50 rounded-lg p-3 text-center">
|
||||
<div className="text-lg font-bold text-yellow-600">
|
||||
{metrics.filter(m => m.percentile < 50).length}
|
||||
</div>
|
||||
<div className="text-xs text-yellow-700">Áreas de Oportunidad</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Category Filters */}
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
{categories.map(category => (
|
||||
<button
|
||||
key={category.id}
|
||||
onClick={() => setSelectedCategory(category.id)}
|
||||
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition-colors ${
|
||||
selectedCategory === category.id
|
||||
? 'bg-indigo-100 text-indigo-800 border border-indigo-200'
|
||||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
{category.name}
|
||||
<span className="ml-1.5 text-xs bg-white rounded-full px-1.5 py-0.5">
|
||||
{category.count}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Metrics List */}
|
||||
<div className="divide-y divide-gray-100">
|
||||
{filteredMetrics.map(metric => {
|
||||
const performance = getPerformanceLevel(metric.percentile);
|
||||
const vsAverage = getComparisonPercentage(
|
||||
metric.yourValue,
|
||||
metric.industryAverage,
|
||||
isLowerBetter(metric.id)
|
||||
);
|
||||
const vsTop = getComparisonPercentage(
|
||||
metric.yourValue,
|
||||
metric.topPerformers,
|
||||
isLowerBetter(metric.id)
|
||||
);
|
||||
|
||||
return (
|
||||
<div key={metric.id} className="p-6">
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center space-x-3">
|
||||
<h4 className="text-lg font-medium text-gray-900">
|
||||
{metric.name}
|
||||
</h4>
|
||||
{getTrendIcon(metric.trend)}
|
||||
<span className={`px-2 py-1 text-xs rounded-full ${performance.bg} ${performance.color}`}>
|
||||
{performance.label}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 mt-1">{metric.description}</p>
|
||||
</div>
|
||||
|
||||
<div className="text-right ml-4">
|
||||
<div className="text-2xl font-bold text-gray-900">
|
||||
{metric.yourValue.toLocaleString('es-ES')}<span className="text-sm text-gray-500">{metric.unit}</span>
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">Tu Resultado</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Comparison Bars */}
|
||||
<div className="space-y-3">
|
||||
{/* Your Performance */}
|
||||
<div className="relative">
|
||||
<div className="flex justify-between items-center mb-1">
|
||||
<span className="text-sm font-medium text-gray-900">Tu Rendimiento</span>
|
||||
<span className="text-sm text-indigo-600 font-medium">Percentil {metric.percentile}</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 rounded-full h-2">
|
||||
<div
|
||||
className="bg-indigo-600 h-2 rounded-full transition-all duration-300"
|
||||
style={{ width: `${metric.percentile}%` }}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Industry Average */}
|
||||
<div className="relative">
|
||||
<div className="flex justify-between items-center mb-1">
|
||||
<span className="text-sm text-gray-600">Promedio Industria</span>
|
||||
<span className="text-sm text-gray-600">
|
||||
{metric.industryAverage.toLocaleString('es-ES')}{metric.unit}
|
||||
<span className={`ml-2 ${vsAverage.isPositive ? 'text-green-600' : 'text-red-600'}`}>
|
||||
({vsAverage.isPositive ? '+' : '-'}{vsAverage.value.toFixed(1)}%)
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 rounded-full h-1.5">
|
||||
<div
|
||||
className="bg-gray-400 h-1.5 rounded-full"
|
||||
style={{ width: '50%' }}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Top Performers */}
|
||||
<div className="relative">
|
||||
<div className="flex justify-between items-center mb-1">
|
||||
<span className="text-sm text-gray-600 flex items-center">
|
||||
<Award className="h-3 w-3 mr-1 text-yellow-500" />
|
||||
Top Performers
|
||||
</span>
|
||||
<span className="text-sm text-gray-600">
|
||||
{metric.topPerformers.toLocaleString('es-ES')}{metric.unit}
|
||||
<span className={`ml-2 ${vsTop.isPositive ? 'text-green-600' : 'text-orange-600'}`}>
|
||||
({vsTop.isPositive ? '+' : '-'}{vsTop.value.toFixed(1)}%)
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 rounded-full h-1.5">
|
||||
<div
|
||||
className="bg-yellow-400 h-1.5 rounded-full"
|
||||
style={{ width: '90%' }}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Insights */}
|
||||
{showDetails && (
|
||||
<div className="mt-4 p-4 bg-gray-50 rounded-lg">
|
||||
<h5 className="text-sm font-medium text-gray-700 mb-2 flex items-center">
|
||||
<Target className="h-4 w-4 mr-2 text-indigo-600" />
|
||||
Insights Clave:
|
||||
</h5>
|
||||
<ul className="space-y-1">
|
||||
{metric.insights.map((insight, index) => (
|
||||
<li key={index} className="text-sm text-gray-600 flex items-start">
|
||||
<div className="w-1.5 h-1.5 bg-indigo-400 rounded-full mt-2 mr-2 flex-shrink-0"></div>
|
||||
{insight}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{filteredMetrics.length === 0 && (
|
||||
<div className="p-8 text-center">
|
||||
<BarChart3 className="h-12 w-12 text-gray-300 mx-auto mb-4" />
|
||||
<p className="text-gray-500">No hay métricas disponibles</p>
|
||||
<p className="text-sm text-gray-400 mt-1">
|
||||
Los benchmarks aparecerán cuando haya suficientes datos
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Disclaimer */}
|
||||
<div className="px-6 pb-4">
|
||||
<div className="bg-gray-50 rounded-lg p-3">
|
||||
<div className="text-xs text-gray-600">
|
||||
<strong>🔒 Privacidad:</strong> Todos los datos están anonimizados.
|
||||
Solo se comparten métricas agregadas de panaderías similares en tamaño y ubicación.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CompetitiveBenchmarks;
|
||||
287
frontend/src/components/ui/DemandHeatmap.tsx
Normal file
287
frontend/src/components/ui/DemandHeatmap.tsx
Normal file
@@ -0,0 +1,287 @@
|
||||
import { useState } from 'react';
|
||||
import { ChevronLeft, ChevronRight, Calendar, TrendingUp, Eye } from 'lucide-react';
|
||||
|
||||
export interface DayDemand {
|
||||
date: string;
|
||||
demand: number;
|
||||
isToday?: boolean;
|
||||
isForecast?: boolean;
|
||||
products?: Array<{
|
||||
name: string;
|
||||
demand: number;
|
||||
confidence: 'high' | 'medium' | 'low';
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface WeekData {
|
||||
weekStart: string;
|
||||
days: DayDemand[];
|
||||
}
|
||||
|
||||
interface DemandHeatmapProps {
|
||||
data: WeekData[];
|
||||
selectedProduct?: string;
|
||||
onDateClick?: (date: string) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const DemandHeatmap: React.FC<DemandHeatmapProps> = ({
|
||||
data,
|
||||
selectedProduct,
|
||||
onDateClick,
|
||||
className = ''
|
||||
}) => {
|
||||
const [currentWeekIndex, setCurrentWeekIndex] = useState(0);
|
||||
const [selectedDate, setSelectedDate] = useState<string | null>(null);
|
||||
|
||||
const maxDemand = Math.max(
|
||||
...data.flatMap(week => week.days.map(day => day.demand))
|
||||
);
|
||||
|
||||
const getDemandIntensity = (demand: number) => {
|
||||
const intensity = demand / maxDemand;
|
||||
if (intensity > 0.8) return 'bg-red-500';
|
||||
if (intensity > 0.6) return 'bg-orange-500';
|
||||
if (intensity > 0.4) return 'bg-yellow-500';
|
||||
if (intensity > 0.2) return 'bg-green-500';
|
||||
return 'bg-gray-200';
|
||||
};
|
||||
|
||||
const getDemandLabel = (demand: number) => {
|
||||
const intensity = demand / maxDemand;
|
||||
if (intensity > 0.8) return 'Muy Alta';
|
||||
if (intensity > 0.6) return 'Alta';
|
||||
if (intensity > 0.4) return 'Media';
|
||||
if (intensity > 0.2) return 'Baja';
|
||||
return 'Muy Baja';
|
||||
};
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
const date = new Date(dateStr);
|
||||
return date.getDate().toString();
|
||||
};
|
||||
|
||||
const formatWeekRange = (weekStart: string) => {
|
||||
const start = new Date(weekStart);
|
||||
const end = new Date(start);
|
||||
end.setDate(end.getDate() + 6);
|
||||
|
||||
return `${start.getDate()}-${end.getDate()} ${start.toLocaleDateString('es-ES', { month: 'short' })}`;
|
||||
};
|
||||
|
||||
const handleDateClick = (date: string) => {
|
||||
setSelectedDate(date);
|
||||
onDateClick?.(date);
|
||||
};
|
||||
|
||||
const currentWeek = data[currentWeekIndex];
|
||||
const selectedDay = selectedDate ?
|
||||
data.flatMap(w => w.days).find(d => d.date === selectedDate) : null;
|
||||
|
||||
return (
|
||||
<div className={`bg-white rounded-xl shadow-soft p-6 ${className}`}>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 flex items-center">
|
||||
<Calendar className="h-5 w-5 mr-2 text-primary-600" />
|
||||
Mapa de Calor de Demanda
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 mt-1">
|
||||
Patrones de demanda visual por día
|
||||
{selectedProduct && ` - ${selectedProduct}`}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<button
|
||||
onClick={() => setCurrentWeekIndex(Math.max(0, currentWeekIndex - 1))}
|
||||
disabled={currentWeekIndex === 0}
|
||||
className="p-2 rounded-lg bg-gray-100 hover:bg-gray-200 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
<span className="text-sm font-medium text-gray-700 min-w-[100px] text-center">
|
||||
{currentWeek ? formatWeekRange(currentWeek.weekStart) : 'Esta Semana'}
|
||||
</span>
|
||||
|
||||
<button
|
||||
onClick={() => setCurrentWeekIndex(Math.min(data.length - 1, currentWeekIndex + 1))}
|
||||
disabled={currentWeekIndex === data.length - 1}
|
||||
className="p-2 rounded-lg bg-gray-100 hover:bg-gray-200 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Heatmap Grid */}
|
||||
<div className="grid grid-cols-7 gap-2 mb-6">
|
||||
{['Lun', 'Mar', 'Mié', 'Jue', 'Vie', 'Sáb', 'Dom'].map(day => (
|
||||
<div key={day} className="text-center text-xs font-medium text-gray-600 p-2">
|
||||
{day}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{currentWeek?.days.map(day => (
|
||||
<button
|
||||
key={day.date}
|
||||
onClick={() => handleDateClick(day.date)}
|
||||
className={`
|
||||
relative p-3 rounded-lg text-white text-sm font-medium
|
||||
transition-all duration-200 hover:scale-105 hover:shadow-md
|
||||
${getDemandIntensity(day.demand)}
|
||||
${selectedDate === day.date ? 'ring-2 ring-primary-600 ring-offset-2' : ''}
|
||||
${day.isToday ? 'ring-2 ring-blue-400' : ''}
|
||||
`}
|
||||
>
|
||||
<div className="text-center">
|
||||
<div className="text-lg font-bold">{formatDate(day.date)}</div>
|
||||
<div className="text-xs opacity-90">{day.demand}</div>
|
||||
{day.isForecast && (
|
||||
<div className="absolute -top-1 -right-1 w-2 h-2 bg-blue-400 rounded-full"></div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Legend */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center space-x-4">
|
||||
<span className="text-sm text-gray-600">Demanda:</span>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="w-4 h-4 bg-gray-200 rounded"></div>
|
||||
<span className="text-xs">Muy Baja</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="w-4 h-4 bg-green-500 rounded"></div>
|
||||
<span className="text-xs">Baja</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="w-4 h-4 bg-yellow-500 rounded"></div>
|
||||
<span className="text-xs">Media</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="w-4 h-4 bg-orange-500 rounded"></div>
|
||||
<span className="text-xs">Alta</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="w-4 h-4 bg-red-500 rounded"></div>
|
||||
<span className="text-xs">Muy Alta</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-4 text-xs text-gray-600">
|
||||
<div className="flex items-center">
|
||||
<div className="w-2 h-2 bg-blue-400 rounded-full mr-1"></div>
|
||||
Predicción
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<div className="w-2 h-2 bg-blue-600 rounded-full mr-1"></div>
|
||||
Hoy
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Selected Day Details */}
|
||||
{selectedDay && (
|
||||
<div className="border-t border-gray-200 pt-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h4 className="font-medium text-gray-900 flex items-center">
|
||||
<Eye className="h-4 w-4 mr-2 text-primary-600" />
|
||||
{new Date(selectedDay.date).toLocaleDateString('es-ES', {
|
||||
weekday: 'long',
|
||||
day: 'numeric',
|
||||
month: 'long'
|
||||
})}
|
||||
{selectedDay.isToday && (
|
||||
<span className="ml-2 px-2 py-1 bg-blue-100 text-blue-800 text-xs rounded-full">
|
||||
Hoy
|
||||
</span>
|
||||
)}
|
||||
</h4>
|
||||
<div className="mt-2 flex items-center space-x-4">
|
||||
<div className="flex items-center">
|
||||
<TrendingUp className="h-4 w-4 text-gray-600 mr-1" />
|
||||
<span className="text-sm text-gray-600">
|
||||
Demanda Total: <span className="font-medium">{selectedDay.demand}</span>
|
||||
</span>
|
||||
</div>
|
||||
<span className={`px-2 py-1 text-xs rounded-full ${
|
||||
getDemandIntensity(selectedDay.demand)
|
||||
} text-white`}>
|
||||
{getDemandLabel(selectedDay.demand)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Product Breakdown */}
|
||||
{selectedDay.products && (
|
||||
<div className="mt-4">
|
||||
<h5 className="text-sm font-medium text-gray-700 mb-3">Desglose por Producto:</h5>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
|
||||
{selectedDay.products.map((product, index) => (
|
||||
<div key={index} className="bg-gray-50 rounded-lg p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-gray-900">{product.name}</span>
|
||||
<span className={`px-2 py-1 text-xs rounded ${
|
||||
product.confidence === 'high' ? 'bg-green-100 text-green-800' :
|
||||
product.confidence === 'medium' ? 'bg-yellow-100 text-yellow-800' :
|
||||
'bg-red-100 text-red-800'
|
||||
}`}>
|
||||
{product.confidence === 'high' ? 'Alta' :
|
||||
product.confidence === 'medium' ? 'Media' : 'Baja'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-lg font-bold text-gray-900 mt-1">
|
||||
{product.demand}
|
||||
</div>
|
||||
<div className="text-xs text-gray-600">unidades</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Weekly Summary */}
|
||||
{currentWeek && (
|
||||
<div className="mt-6 bg-gray-50 rounded-lg p-4">
|
||||
<h5 className="text-sm font-medium text-gray-700 mb-2">Resumen Semanal:</h5>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-center">
|
||||
<div>
|
||||
<div className="text-lg font-bold text-gray-900">
|
||||
{currentWeek.days.reduce((sum, day) => sum + day.demand, 0)}
|
||||
</div>
|
||||
<div className="text-xs text-gray-600">Total</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-lg font-bold text-gray-900">
|
||||
{Math.round(currentWeek.days.reduce((sum, day) => sum + day.demand, 0) / 7)}
|
||||
</div>
|
||||
<div className="text-xs text-gray-600">Promedio/día</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-lg font-bold text-gray-900">
|
||||
{Math.max(...currentWeek.days.map(d => d.demand))}
|
||||
</div>
|
||||
<div className="text-xs text-gray-600">Pico</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-lg font-bold text-gray-900">
|
||||
{currentWeek.days.filter(d => d.isForecast).length}
|
||||
</div>
|
||||
<div className="text-xs text-gray-600">Predicciones</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DemandHeatmap;
|
||||
207
frontend/src/components/ui/ProductionSchedule.tsx
Normal file
207
frontend/src/components/ui/ProductionSchedule.tsx
Normal file
@@ -0,0 +1,207 @@
|
||||
import React from 'react';
|
||||
import { Clock, CheckCircle, AlertTriangle, TrendingUp } from 'lucide-react';
|
||||
|
||||
export interface ProductionItem {
|
||||
id: string;
|
||||
product: string;
|
||||
quantity: number;
|
||||
priority: 'high' | 'medium' | 'low';
|
||||
estimatedTime: number; // minutes
|
||||
status: 'pending' | 'in_progress' | 'completed';
|
||||
confidence: number; // 0-1
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface ProductionTimeSlot {
|
||||
time: string;
|
||||
items: ProductionItem[];
|
||||
totalTime: number;
|
||||
}
|
||||
|
||||
interface ProductionScheduleProps {
|
||||
schedule: ProductionTimeSlot[];
|
||||
onUpdateQuantity?: (itemId: string, newQuantity: number) => void;
|
||||
onUpdateStatus?: (itemId: string, status: ProductionItem['status']) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const getPriorityConfig = (priority: ProductionItem['priority']) => {
|
||||
switch (priority) {
|
||||
case 'high':
|
||||
return {
|
||||
bgColor: 'bg-red-50',
|
||||
borderColor: 'border-red-200',
|
||||
textColor: 'text-red-800',
|
||||
label: 'ALTA'
|
||||
};
|
||||
case 'medium':
|
||||
return {
|
||||
bgColor: 'bg-yellow-50',
|
||||
borderColor: 'border-yellow-200',
|
||||
textColor: 'text-yellow-800',
|
||||
label: 'MEDIA'
|
||||
};
|
||||
default:
|
||||
return {
|
||||
bgColor: 'bg-green-50',
|
||||
borderColor: 'border-green-200',
|
||||
textColor: 'text-green-800',
|
||||
label: 'BAJA'
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusIcon = (status: ProductionItem['status']) => {
|
||||
switch (status) {
|
||||
case 'completed':
|
||||
return <CheckCircle className="h-4 w-4 text-success-600" />;
|
||||
case 'in_progress':
|
||||
return <Clock className="h-4 w-4 text-primary-600" />;
|
||||
default:
|
||||
return <AlertTriangle className="h-4 w-4 text-gray-400" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getConfidenceColor = (confidence: number) => {
|
||||
if (confidence >= 0.8) return 'text-success-600';
|
||||
if (confidence >= 0.6) return 'text-warning-600';
|
||||
return 'text-danger-600';
|
||||
};
|
||||
|
||||
const ProductionItem: React.FC<{
|
||||
item: ProductionItem;
|
||||
onUpdateQuantity?: (itemId: string, newQuantity: number) => void;
|
||||
onUpdateStatus?: (itemId: string, status: ProductionItem['status']) => void;
|
||||
}> = ({ item, onUpdateQuantity, onUpdateStatus }) => {
|
||||
const priorityConfig = getPriorityConfig(item.priority);
|
||||
|
||||
const handleQuantityChange = (delta: number) => {
|
||||
const newQuantity = Math.max(0, item.quantity + delta);
|
||||
onUpdateQuantity?.(item.id, newQuantity);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white border border-gray-200 rounded-lg p-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
{getStatusIcon(item.status)}
|
||||
<span className="font-medium text-gray-900">{item.product}</span>
|
||||
<span className={`px-2 py-1 text-xs font-medium rounded ${priorityConfig.bgColor} ${priorityConfig.borderColor} ${priorityConfig.textColor}`}>
|
||||
{priorityConfig.label}
|
||||
</span>
|
||||
</div>
|
||||
<div className={`text-sm font-medium ${getConfidenceColor(item.confidence)}`}>
|
||||
{Math.round(item.confidence * 100)}% confianza
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="flex items-center space-x-1">
|
||||
<button
|
||||
onClick={() => handleQuantityChange(-5)}
|
||||
className="w-6 h-6 rounded-full bg-gray-100 hover:bg-gray-200 flex items-center justify-center text-sm font-medium"
|
||||
disabled={item.quantity <= 5}
|
||||
>
|
||||
-
|
||||
</button>
|
||||
<span className="w-12 text-center font-bold text-lg">{item.quantity}</span>
|
||||
<button
|
||||
onClick={() => handleQuantityChange(5)}
|
||||
className="w-6 h-6 rounded-full bg-gray-100 hover:bg-gray-200 flex items-center justify-center text-sm font-medium"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
<span className="text-sm text-gray-600">unidades</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2 text-sm text-gray-600">
|
||||
<Clock className="h-4 w-4" />
|
||||
<span>{item.estimatedTime} min</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{item.notes && (
|
||||
<div className="text-sm text-gray-600 bg-gray-50 rounded p-2">
|
||||
{item.notes}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex space-x-2">
|
||||
{item.status === 'pending' && (
|
||||
<button
|
||||
onClick={() => onUpdateStatus?.(item.id, 'in_progress')}
|
||||
className="flex-1 px-3 py-2 text-sm font-medium text-primary-700 bg-primary-100 hover:bg-primary-200 rounded transition-colors"
|
||||
>
|
||||
Iniciar Producción
|
||||
</button>
|
||||
)}
|
||||
{item.status === 'in_progress' && (
|
||||
<button
|
||||
onClick={() => onUpdateStatus?.(item.id, 'completed')}
|
||||
className="flex-1 px-3 py-2 text-sm font-medium text-success-700 bg-success-100 hover:bg-success-200 rounded transition-colors"
|
||||
>
|
||||
Marcar Completado
|
||||
</button>
|
||||
)}
|
||||
{item.status === 'completed' && (
|
||||
<div className="flex-1 px-3 py-2 text-sm font-medium text-success-700 bg-success-100 rounded text-center">
|
||||
✓ Completado
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ProductionSchedule: React.FC<ProductionScheduleProps> = ({
|
||||
schedule,
|
||||
onUpdateQuantity,
|
||||
onUpdateStatus,
|
||||
className = ''
|
||||
}) => {
|
||||
const getTotalItems = (items: ProductionItem[]) => {
|
||||
return items.reduce((sum, item) => sum + item.quantity, 0);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`space-y-6 ${className}`}>
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold text-gray-900">
|
||||
Plan de Producción de Hoy
|
||||
</h3>
|
||||
<div className="flex items-center space-x-2 text-sm text-gray-600">
|
||||
<TrendingUp className="h-4 w-4" />
|
||||
<span>Optimizado por IA</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{schedule.map((timeSlot, index) => (
|
||||
<div key={index} className="space-y-4">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="bg-primary-100 text-primary-800 px-3 py-1 rounded-lg font-medium text-sm">
|
||||
{timeSlot.time}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">
|
||||
{getTotalItems(timeSlot.items)} unidades • {timeSlot.totalTime} min total
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{timeSlot.items.map((item) => (
|
||||
<ProductionItem
|
||||
key={item.id}
|
||||
item={item}
|
||||
onUpdateQuantity={onUpdateQuantity}
|
||||
onUpdateStatus={onUpdateStatus}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProductionSchedule;
|
||||
373
frontend/src/components/ui/QuickActionsPanel.tsx
Normal file
373
frontend/src/components/ui/QuickActionsPanel.tsx
Normal file
@@ -0,0 +1,373 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Zap, Plus, Minus, RotateCcw, TrendingUp, ShoppingCart,
|
||||
Clock, AlertTriangle, Phone, MessageSquare, Calculator,
|
||||
RefreshCw, Package, Users, Settings, ChevronRight
|
||||
} from 'lucide-react';
|
||||
|
||||
interface QuickAction {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
icon: any;
|
||||
category: 'production' | 'inventory' | 'sales' | 'customer' | 'system';
|
||||
shortcut?: string;
|
||||
requiresConfirmation?: boolean;
|
||||
estimatedTime?: string;
|
||||
badge?: {
|
||||
text: string;
|
||||
color: 'red' | 'yellow' | 'green' | 'blue' | 'purple';
|
||||
};
|
||||
}
|
||||
|
||||
interface QuickActionsPanelProps {
|
||||
onActionClick?: (actionId: string) => void;
|
||||
availableActions?: QuickAction[];
|
||||
compactMode?: boolean;
|
||||
showCategories?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const QuickActionsPanel: React.FC<QuickActionsPanelProps> = ({
|
||||
onActionClick,
|
||||
availableActions,
|
||||
compactMode = false,
|
||||
showCategories = true,
|
||||
className = ''
|
||||
}) => {
|
||||
const [selectedCategory, setSelectedCategory] = useState<string>('all');
|
||||
const [actionInProgress, setActionInProgress] = useState<string | null>(null);
|
||||
|
||||
const defaultActions: QuickAction[] = [
|
||||
// Production Actions
|
||||
{
|
||||
id: 'increase_production',
|
||||
title: 'Aumentar Producción',
|
||||
description: 'Incrementa rápidamente la producción del producto más demandado',
|
||||
icon: Plus,
|
||||
category: 'production',
|
||||
shortcut: 'P + A',
|
||||
estimatedTime: '2 min',
|
||||
badge: { text: 'Croissants', color: 'green' }
|
||||
},
|
||||
{
|
||||
id: 'emergency_batch',
|
||||
title: 'Lote de Emergencia',
|
||||
description: 'Inicia producción urgente para productos con stock bajo',
|
||||
icon: AlertTriangle,
|
||||
category: 'production',
|
||||
requiresConfirmation: true,
|
||||
estimatedTime: '45 min',
|
||||
badge: { text: '3 productos', color: 'red' }
|
||||
},
|
||||
{
|
||||
id: 'adjust_schedule',
|
||||
title: 'Ajustar Horario',
|
||||
description: 'Modifica el horario de producción basado en predicciones',
|
||||
icon: Clock,
|
||||
category: 'production',
|
||||
estimatedTime: '1 min'
|
||||
},
|
||||
|
||||
// Inventory Actions
|
||||
{
|
||||
id: 'check_stock',
|
||||
title: 'Revisar Stock',
|
||||
description: 'Verifica niveles de inventario y productos próximos a agotarse',
|
||||
icon: Package,
|
||||
category: 'inventory',
|
||||
shortcut: 'I + S',
|
||||
badge: { text: '2 bajos', color: 'yellow' }
|
||||
},
|
||||
{
|
||||
id: 'order_supplies',
|
||||
title: 'Pedir Suministros',
|
||||
description: 'Genera orden automática de ingredientes basada en predicciones',
|
||||
icon: ShoppingCart,
|
||||
category: 'inventory',
|
||||
estimatedTime: '3 min',
|
||||
badge: { text: 'Harina, Huevos', color: 'blue' }
|
||||
},
|
||||
{
|
||||
id: 'waste_report',
|
||||
title: 'Reportar Desperdicio',
|
||||
description: 'Registra productos no vendidos para mejorar predicciones',
|
||||
icon: Minus,
|
||||
category: 'inventory',
|
||||
estimatedTime: '2 min'
|
||||
},
|
||||
|
||||
// Sales Actions
|
||||
{
|
||||
id: 'price_adjustment',
|
||||
title: 'Ajustar Precios',
|
||||
description: 'Modifica precios para productos con baja rotación',
|
||||
icon: Calculator,
|
||||
category: 'sales',
|
||||
requiresConfirmation: true,
|
||||
badge: { text: 'Magdalenas -10%', color: 'yellow' }
|
||||
},
|
||||
{
|
||||
id: 'promotion_activate',
|
||||
title: 'Activar Promoción',
|
||||
description: 'Inicia promoción instantánea para productos específicos',
|
||||
icon: TrendingUp,
|
||||
category: 'sales',
|
||||
estimatedTime: '1 min',
|
||||
badge: { text: '2x1 Tartas', color: 'green' }
|
||||
},
|
||||
|
||||
// Customer Actions
|
||||
{
|
||||
id: 'notify_customers',
|
||||
title: 'Avisar Clientes',
|
||||
description: 'Notifica a clientes regulares sobre disponibilidad especial',
|
||||
icon: MessageSquare,
|
||||
category: 'customer',
|
||||
badge: { text: '15 clientes', color: 'blue' }
|
||||
},
|
||||
{
|
||||
id: 'call_supplier',
|
||||
title: 'Llamar Proveedor',
|
||||
description: 'Contacto rápido con proveedor principal',
|
||||
icon: Phone,
|
||||
category: 'customer',
|
||||
estimatedTime: '5 min'
|
||||
},
|
||||
|
||||
// System Actions
|
||||
{
|
||||
id: 'refresh_predictions',
|
||||
title: 'Actualizar Predicciones',
|
||||
description: 'Recalcula predicciones con datos más recientes',
|
||||
icon: RefreshCw,
|
||||
category: 'system',
|
||||
estimatedTime: '30 seg'
|
||||
},
|
||||
{
|
||||
id: 'backup_data',
|
||||
title: 'Respaldar Datos',
|
||||
description: 'Crea respaldo de la información del día',
|
||||
icon: RotateCcw,
|
||||
category: 'system',
|
||||
estimatedTime: '1 min'
|
||||
}
|
||||
];
|
||||
|
||||
const actions = availableActions || defaultActions;
|
||||
|
||||
const categories = [
|
||||
{ id: 'all', name: 'Todas', icon: Zap },
|
||||
{ id: 'production', name: 'Producción', icon: Package },
|
||||
{ id: 'inventory', name: 'Inventario', icon: ShoppingCart },
|
||||
{ id: 'sales', name: 'Ventas', icon: TrendingUp },
|
||||
{ id: 'customer', name: 'Clientes', icon: Users },
|
||||
{ id: 'system', name: 'Sistema', icon: Settings }
|
||||
];
|
||||
|
||||
const filteredActions = actions.filter(action =>
|
||||
selectedCategory === 'all' || action.category === selectedCategory
|
||||
);
|
||||
|
||||
const handleActionClick = async (action: QuickAction) => {
|
||||
if (action.requiresConfirmation) {
|
||||
const confirmed = window.confirm(`¿Estás seguro de que quieres ejecutar "${action.title}"?`);
|
||||
if (!confirmed) return;
|
||||
}
|
||||
|
||||
setActionInProgress(action.id);
|
||||
|
||||
// Simulate action execution
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
|
||||
setActionInProgress(null);
|
||||
onActionClick?.(action.id);
|
||||
};
|
||||
|
||||
const getBadgeColors = (color: string) => {
|
||||
const colors = {
|
||||
red: 'bg-red-100 text-red-800',
|
||||
yellow: 'bg-yellow-100 text-yellow-800',
|
||||
green: 'bg-green-100 text-green-800',
|
||||
blue: 'bg-blue-100 text-blue-800',
|
||||
purple: 'bg-purple-100 text-purple-800'
|
||||
};
|
||||
return colors[color as keyof typeof colors] || colors.blue;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`bg-white rounded-xl shadow-soft ${className}`}>
|
||||
{/* Header */}
|
||||
<div className="p-6 border-b border-gray-200">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
<Zap className="h-6 w-6 text-yellow-600 mr-3" />
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">
|
||||
Acciones Rápidas
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600">
|
||||
Tareas comunes del día a día
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-right">
|
||||
<div className="text-sm text-gray-500">
|
||||
{filteredActions.length} acciones disponibles
|
||||
</div>
|
||||
<div className="text-xs text-gray-400">
|
||||
Usa atajos de teclado para mayor velocidad
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Category Filters */}
|
||||
{showCategories && !compactMode && (
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
{categories.map(category => {
|
||||
const IconComponent = category.icon;
|
||||
const isSelected = selectedCategory === category.id;
|
||||
return (
|
||||
<button
|
||||
key={category.id}
|
||||
onClick={() => setSelectedCategory(category.id)}
|
||||
className={`flex items-center px-3 py-1.5 rounded-lg text-sm font-medium transition-colors ${
|
||||
isSelected
|
||||
? 'bg-yellow-100 text-yellow-800 border border-yellow-200'
|
||||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 border border-transparent'
|
||||
}`}
|
||||
>
|
||||
<IconComponent className="h-4 w-4 mr-1.5" />
|
||||
{category.name}
|
||||
{category.id !== 'all' && (
|
||||
<span className="ml-1.5 text-xs bg-white rounded-full px-1.5 py-0.5">
|
||||
{actions.filter(a => a.category === category.id).length}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions Grid */}
|
||||
<div className="p-6">
|
||||
<div className={`grid gap-4 ${
|
||||
compactMode
|
||||
? 'grid-cols-1'
|
||||
: 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-3'
|
||||
}`}>
|
||||
{filteredActions.map(action => {
|
||||
const IconComponent = action.icon;
|
||||
const isInProgress = actionInProgress === action.id;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={action.id}
|
||||
onClick={() => handleActionClick(action)}
|
||||
disabled={isInProgress}
|
||||
className={`relative group text-left p-4 border border-gray-200 rounded-lg hover:border-yellow-300 hover:shadow-md transition-all duration-200 ${
|
||||
isInProgress ? 'opacity-50 cursor-not-allowed' : 'hover:scale-[1.02]'
|
||||
} ${compactMode ? 'flex items-center' : 'block'}`}
|
||||
>
|
||||
{/* Loading overlay */}
|
||||
{isInProgress && (
|
||||
<div className="absolute inset-0 bg-white bg-opacity-90 rounded-lg flex items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-yellow-600"></div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={`flex ${compactMode ? 'items-center space-x-3' : 'items-start justify-between'}`}>
|
||||
<div className={`flex ${compactMode ? 'items-center space-x-3' : 'items-start space-x-3'}`}>
|
||||
{/* Icon */}
|
||||
<div className="flex-shrink-0 p-2 bg-yellow-50 rounded-lg group-hover:bg-yellow-100 transition-colors">
|
||||
<IconComponent className="h-5 w-5 text-yellow-600" />
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="font-medium text-gray-900 group-hover:text-yellow-700">
|
||||
{action.title}
|
||||
</h4>
|
||||
|
||||
{/* Badges */}
|
||||
<div className="flex items-center space-x-2 ml-2">
|
||||
{action.badge && (
|
||||
<span className={`px-2 py-1 text-xs rounded-full ${getBadgeColors(action.badge.color)}`}>
|
||||
{action.badge.text}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{action.shortcut && !compactMode && (
|
||||
<span className="px-2 py-1 text-xs bg-gray-100 text-gray-600 rounded border font-mono">
|
||||
{action.shortcut}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!compactMode && (
|
||||
<p className="text-sm text-gray-600 mt-1 line-clamp-2">
|
||||
{action.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Metadata */}
|
||||
<div className="flex items-center mt-2 space-x-3">
|
||||
{action.estimatedTime && (
|
||||
<span className="flex items-center text-xs text-gray-500">
|
||||
<Clock className="h-3 w-3 mr-1" />
|
||||
{action.estimatedTime}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{action.requiresConfirmation && (
|
||||
<span className="flex items-center text-xs text-orange-600">
|
||||
<AlertTriangle className="h-3 w-3 mr-1" />
|
||||
Requiere confirmación
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Arrow indicator */}
|
||||
{!compactMode && (
|
||||
<ChevronRight className="h-4 w-4 text-gray-400 group-hover:text-yellow-600 transition-colors flex-shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{filteredActions.length === 0 && (
|
||||
<div className="text-center py-8">
|
||||
<Zap className="h-12 w-12 text-gray-300 mx-auto mb-4" />
|
||||
<p className="text-gray-500">No hay acciones disponibles</p>
|
||||
<p className="text-sm text-gray-400 mt-1">
|
||||
Las acciones aparecerán basadas en el estado actual de tu panadería
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Keyboard Shortcuts Help */}
|
||||
{!compactMode && (
|
||||
<div className="px-6 pb-4">
|
||||
<div className="bg-gray-50 rounded-lg p-3">
|
||||
<div className="flex items-center justify-between text-xs text-gray-600">
|
||||
<span className="font-medium">💡 Tip:</span>
|
||||
<span>Usa Ctrl + K para búsqueda rápida de acciones</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default QuickActionsPanel;
|
||||
107
frontend/src/components/ui/RevenueMetrics.tsx
Normal file
107
frontend/src/components/ui/RevenueMetrics.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
import React from 'react';
|
||||
import { Euro, TrendingUp, TrendingDown, AlertCircle } from 'lucide-react';
|
||||
|
||||
export interface RevenueData {
|
||||
projectedDailyRevenue: number;
|
||||
lostRevenueFromStockouts: number;
|
||||
wasteCost: number;
|
||||
revenueTrend: 'up' | 'down' | 'stable';
|
||||
trendPercentage: number;
|
||||
currency: string;
|
||||
}
|
||||
|
||||
interface RevenueMetricsProps {
|
||||
revenueData: RevenueData;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const formatCurrency = (amount: number, currency: string = '€') => {
|
||||
return `${amount.toFixed(0)}${currency}`;
|
||||
};
|
||||
|
||||
const RevenueMetrics: React.FC<RevenueMetricsProps> = ({ revenueData, className = '' }) => {
|
||||
const getTrendIcon = () => {
|
||||
switch (revenueData.revenueTrend) {
|
||||
case 'up':
|
||||
return <TrendingUp className="h-4 w-4 text-success-600" />;
|
||||
case 'down':
|
||||
return <TrendingDown className="h-4 w-4 text-danger-600" />;
|
||||
default:
|
||||
return <TrendingUp className="h-4 w-4 text-gray-600" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getTrendColor = () => {
|
||||
switch (revenueData.revenueTrend) {
|
||||
case 'up':
|
||||
return 'text-success-600';
|
||||
case 'down':
|
||||
return 'text-danger-600';
|
||||
default:
|
||||
return 'text-gray-600';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`grid grid-cols-1 md:grid-cols-3 gap-4 ${className}`}>
|
||||
{/* Projected Daily Revenue */}
|
||||
<div className="bg-white p-6 rounded-xl shadow-soft">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="p-2 bg-success-100 rounded-lg">
|
||||
<Euro className="h-6 w-6 text-success-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">Ingresos Previstos Hoy</p>
|
||||
<p className="text-2xl font-bold text-gray-900">
|
||||
{formatCurrency(revenueData.projectedDailyRevenue, revenueData.currency)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className={`flex items-center mt-2 text-sm ${getTrendColor()}`}>
|
||||
{getTrendIcon()}
|
||||
<span className="ml-1">
|
||||
{revenueData.trendPercentage > 0 ? '+' : ''}{revenueData.trendPercentage}% vs ayer
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Lost Revenue from Stockouts */}
|
||||
<div className="bg-white p-6 rounded-xl shadow-soft">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="p-2 bg-danger-100 rounded-lg">
|
||||
<AlertCircle className="h-6 w-6 text-danger-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">Ventas Perdidas</p>
|
||||
<p className="text-2xl font-bold text-danger-700">
|
||||
-{formatCurrency(revenueData.lostRevenueFromStockouts, revenueData.currency)}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 mt-1">Por falta de stock (últimos 7 días)</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Waste Cost Tracker */}
|
||||
<div className="bg-white p-6 rounded-xl shadow-soft">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="p-2 bg-warning-100 rounded-lg">
|
||||
<TrendingDown className="h-6 w-6 text-warning-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">Coste Desperdicio</p>
|
||||
<p className="text-2xl font-bold text-warning-700">
|
||||
-{formatCurrency(revenueData.wasteCost, revenueData.currency)}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 mt-1">Productos no vendidos (esta semana)</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RevenueMetrics;
|
||||
557
frontend/src/components/ui/WhatIfPlanner.tsx
Normal file
557
frontend/src/components/ui/WhatIfPlanner.tsx
Normal file
@@ -0,0 +1,557 @@
|
||||
import { useState } from 'react';
|
||||
import { Play, RotateCcw, TrendingUp, TrendingDown, AlertTriangle, Euro, Calendar } from 'lucide-react';
|
||||
|
||||
interface Scenario {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
type: 'weather' | 'promotion' | 'event' | 'supply' | 'custom';
|
||||
icon: any;
|
||||
parameters: {
|
||||
[key: string]: {
|
||||
label: string;
|
||||
type: 'number' | 'select' | 'boolean';
|
||||
value: any;
|
||||
options?: string[];
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
unit?: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
interface ScenarioResult {
|
||||
scenarioId: string;
|
||||
demandChange: number;
|
||||
revenueImpact: number;
|
||||
productImpacts: Array<{
|
||||
name: string;
|
||||
demandChange: number;
|
||||
newDemand: number;
|
||||
revenueImpact: number;
|
||||
}>;
|
||||
recommendations: string[];
|
||||
confidence: 'high' | 'medium' | 'low';
|
||||
}
|
||||
|
||||
interface WhatIfPlannerProps {
|
||||
baselineData?: {
|
||||
totalDemand: number;
|
||||
totalRevenue: number;
|
||||
products: Array<{
|
||||
name: string;
|
||||
demand: number;
|
||||
price: number;
|
||||
}>;
|
||||
};
|
||||
onScenarioRun?: (scenario: Scenario, result: ScenarioResult) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const WhatIfPlanner: React.FC<WhatIfPlannerProps> = ({
|
||||
baselineData = {
|
||||
totalDemand: 180,
|
||||
totalRevenue: 420,
|
||||
products: [
|
||||
{ name: 'Croissants', demand: 45, price: 2.5 },
|
||||
{ name: 'Pan', demand: 30, price: 1.8 },
|
||||
{ name: 'Magdalenas', demand: 25, price: 1.2 },
|
||||
{ name: 'Empanadas', demand: 20, price: 3.2 },
|
||||
{ name: 'Tartas', demand: 15, price: 12.0 },
|
||||
]
|
||||
},
|
||||
onScenarioRun,
|
||||
className = ''
|
||||
}) => {
|
||||
const [selectedScenario, setSelectedScenario] = useState<string | null>(null);
|
||||
const [scenarioResult, setScenarioResult] = useState<ScenarioResult | null>(null);
|
||||
const [isRunning, setIsRunning] = useState(false);
|
||||
|
||||
const scenarios: Scenario[] = [
|
||||
{
|
||||
id: 'rain',
|
||||
name: 'Día Lluvioso',
|
||||
description: 'Simula el impacto de un día de lluvia en Madrid',
|
||||
type: 'weather',
|
||||
icon: AlertTriangle,
|
||||
parameters: {
|
||||
rainIntensity: {
|
||||
label: 'Intensidad de lluvia',
|
||||
type: 'select',
|
||||
value: 'moderate',
|
||||
options: ['light', 'moderate', 'heavy']
|
||||
},
|
||||
temperature: {
|
||||
label: 'Temperatura (°C)',
|
||||
type: 'number',
|
||||
value: 15,
|
||||
min: 5,
|
||||
max: 25,
|
||||
step: 1,
|
||||
unit: '°C'
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'promotion',
|
||||
name: 'Promoción Especial',
|
||||
description: 'Aplica un descuento y ve el impacto en la demanda',
|
||||
type: 'promotion',
|
||||
icon: TrendingUp,
|
||||
parameters: {
|
||||
discount: {
|
||||
label: 'Descuento',
|
||||
type: 'number',
|
||||
value: 20,
|
||||
min: 5,
|
||||
max: 50,
|
||||
step: 5,
|
||||
unit: '%'
|
||||
},
|
||||
targetProduct: {
|
||||
label: 'Producto objetivo',
|
||||
type: 'select',
|
||||
value: 'Croissants',
|
||||
options: baselineData.products.map(p => p.name)
|
||||
},
|
||||
duration: {
|
||||
label: 'Duración (días)',
|
||||
type: 'number',
|
||||
value: 3,
|
||||
min: 1,
|
||||
max: 7,
|
||||
step: 1,
|
||||
unit: 'días'
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'weekend',
|
||||
name: 'Fin de Semana',
|
||||
description: 'Simula la demanda típica de fin de semana',
|
||||
type: 'event',
|
||||
icon: Calendar,
|
||||
parameters: {
|
||||
dayType: {
|
||||
label: 'Tipo de día',
|
||||
type: 'select',
|
||||
value: 'saturday',
|
||||
options: ['saturday', 'sunday', 'holiday']
|
||||
},
|
||||
weatherGood: {
|
||||
label: 'Buen tiempo',
|
||||
type: 'boolean',
|
||||
value: true
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'supply_shortage',
|
||||
name: 'Escasez de Ingredientes',
|
||||
description: 'Simula falta de ingredientes clave',
|
||||
type: 'supply',
|
||||
icon: AlertTriangle,
|
||||
parameters: {
|
||||
ingredient: {
|
||||
label: 'Ingrediente afectado',
|
||||
type: 'select',
|
||||
value: 'flour',
|
||||
options: ['flour', 'butter', 'eggs', 'sugar', 'chocolate']
|
||||
},
|
||||
shortage: {
|
||||
label: 'Nivel de escasez',
|
||||
type: 'select',
|
||||
value: 'moderate',
|
||||
options: ['mild', 'moderate', 'severe']
|
||||
}
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
const runScenario = async (scenario: Scenario) => {
|
||||
setIsRunning(true);
|
||||
setScenarioResult(null);
|
||||
|
||||
// Simulate API call delay
|
||||
await new Promise(resolve => setTimeout(resolve, 1500));
|
||||
|
||||
// Generate realistic scenario results based on parameters
|
||||
const result = generateScenarioResult(scenario, baselineData);
|
||||
|
||||
setScenarioResult(result);
|
||||
setIsRunning(false);
|
||||
onScenarioRun?.(scenario, result);
|
||||
};
|
||||
|
||||
const generateScenarioResult = (scenario: Scenario, baseline: typeof baselineData): ScenarioResult => {
|
||||
let demandChange = 0;
|
||||
let productImpacts: ScenarioResult['productImpacts'] = [];
|
||||
let recommendations: string[] = [];
|
||||
let confidence: 'high' | 'medium' | 'low' = 'medium';
|
||||
|
||||
switch (scenario.id) {
|
||||
case 'rain':
|
||||
const intensity = scenario.parameters.rainIntensity.value;
|
||||
demandChange = intensity === 'light' ? -5 : intensity === 'moderate' ? -15 : -25;
|
||||
confidence = 'high';
|
||||
recommendations = [
|
||||
'Reduce la producción de productos para llevar',
|
||||
'Aumenta café caliente y productos de temporada',
|
||||
'Prepara promociones para el día siguiente'
|
||||
];
|
||||
productImpacts = baseline.products.map(product => {
|
||||
const change = product.name === 'Croissants' ? demandChange * 1.2 : demandChange;
|
||||
return {
|
||||
name: product.name,
|
||||
demandChange: Math.round(change * product.demand / 100),
|
||||
newDemand: Math.max(0, product.demand + Math.round(change * product.demand / 100)),
|
||||
revenueImpact: Math.round(change * product.demand * product.price / 100)
|
||||
};
|
||||
});
|
||||
break;
|
||||
|
||||
case 'promotion':
|
||||
const discount = scenario.parameters.discount.value;
|
||||
const targetProduct = scenario.parameters.targetProduct.value;
|
||||
demandChange = discount * 1.5; // 20% discount = ~30% demand increase
|
||||
confidence = 'high';
|
||||
recommendations = [
|
||||
`Aumenta la producción de ${targetProduct} en un ${Math.round(demandChange)}%`,
|
||||
'Asegúrate de tener suficientes ingredientes',
|
||||
'Promociona productos complementarios'
|
||||
];
|
||||
productImpacts = baseline.products.map(product => {
|
||||
const change = product.name === targetProduct ? demandChange : demandChange * 0.3;
|
||||
return {
|
||||
name: product.name,
|
||||
demandChange: Math.round(change * product.demand / 100),
|
||||
newDemand: product.demand + Math.round(change * product.demand / 100),
|
||||
revenueImpact: Math.round((change * product.demand / 100) * product.price * (product.name === targetProduct ? (1 - discount/100) : 1))
|
||||
};
|
||||
});
|
||||
break;
|
||||
|
||||
case 'weekend':
|
||||
const isWeekend = scenario.parameters.dayType.value;
|
||||
const goodWeather = scenario.parameters.weatherGood.value;
|
||||
demandChange = (isWeekend === 'saturday' ? 25 : isWeekend === 'sunday' ? 15 : 35) + (goodWeather ? 10 : -5);
|
||||
confidence = 'high';
|
||||
recommendations = [
|
||||
'Aumenta la producción de productos especiales',
|
||||
'Prepara más variedad para familias',
|
||||
'Considera abrir más temprano'
|
||||
];
|
||||
productImpacts = baseline.products.map(product => {
|
||||
const multiplier = product.name === 'Tartas' ? 1.5 : product.name === 'Croissants' ? 1.3 : 1.0;
|
||||
const change = demandChange * multiplier;
|
||||
return {
|
||||
name: product.name,
|
||||
demandChange: Math.round(change * product.demand / 100),
|
||||
newDemand: product.demand + Math.round(change * product.demand / 100),
|
||||
revenueImpact: Math.round(change * product.demand * product.price / 100)
|
||||
};
|
||||
});
|
||||
break;
|
||||
|
||||
case 'supply_shortage':
|
||||
const ingredient = scenario.parameters.ingredient.value;
|
||||
const shortage = scenario.parameters.shortage.value;
|
||||
demandChange = shortage === 'mild' ? -10 : shortage === 'moderate' ? -25 : -40;
|
||||
confidence = 'medium';
|
||||
recommendations = [
|
||||
'Busca proveedores alternativos inmediatamente',
|
||||
'Promociona productos que no requieren este ingrediente',
|
||||
'Informa a los clientes sobre productos no disponibles'
|
||||
];
|
||||
const affectedProducts = getAffectedProducts(ingredient);
|
||||
productImpacts = baseline.products.map(product => {
|
||||
const change = affectedProducts.includes(product.name) ? demandChange : 0;
|
||||
return {
|
||||
name: product.name,
|
||||
demandChange: Math.round(change * product.demand / 100),
|
||||
newDemand: Math.max(0, product.demand + Math.round(change * product.demand / 100)),
|
||||
revenueImpact: Math.round(change * product.demand * product.price / 100)
|
||||
};
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
const totalRevenueImpact = productImpacts.reduce((sum, impact) => sum + impact.revenueImpact, 0);
|
||||
|
||||
return {
|
||||
scenarioId: scenario.id,
|
||||
demandChange,
|
||||
revenueImpact: totalRevenueImpact,
|
||||
productImpacts,
|
||||
recommendations,
|
||||
confidence
|
||||
};
|
||||
};
|
||||
|
||||
const getAffectedProducts = (ingredient: string): string[] => {
|
||||
const ingredientMap: Record<string, string[]> = {
|
||||
flour: ['Pan', 'Croissants', 'Magdalenas', 'Tartas'],
|
||||
butter: ['Croissants', 'Tartas', 'Magdalenas'],
|
||||
eggs: ['Magdalenas', 'Tartas'],
|
||||
sugar: ['Magdalenas', 'Tartas'],
|
||||
chocolate: ['Tartas']
|
||||
};
|
||||
return ingredientMap[ingredient] || [];
|
||||
};
|
||||
|
||||
const updateParameter = (scenarioId: string, paramKey: string, value: any) => {
|
||||
// This would update the scenario parameters in a real implementation
|
||||
console.log('Updating parameter:', scenarioId, paramKey, value);
|
||||
};
|
||||
|
||||
const resetScenario = () => {
|
||||
setSelectedScenario(null);
|
||||
setScenarioResult(null);
|
||||
};
|
||||
|
||||
const selectedScenarioData = scenarios.find(s => s.id === selectedScenario);
|
||||
|
||||
return (
|
||||
<div className={`bg-white rounded-xl shadow-soft p-6 ${className}`}>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 flex items-center">
|
||||
<Play className="h-5 w-5 mr-2 text-primary-600" />
|
||||
Simulador de Escenarios
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 mt-1">
|
||||
Simula diferentes situaciones y ve el impacto en tu negocio
|
||||
</p>
|
||||
</div>
|
||||
{selectedScenario && (
|
||||
<button
|
||||
onClick={resetScenario}
|
||||
className="flex items-center px-3 py-2 text-sm text-gray-600 hover:text-gray-900 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
>
|
||||
<RotateCcw className="h-4 w-4 mr-1" />
|
||||
Reiniciar
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!selectedScenario ? (
|
||||
// Scenario Selection
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{scenarios.map(scenario => {
|
||||
const IconComponent = scenario.icon;
|
||||
return (
|
||||
<button
|
||||
key={scenario.id}
|
||||
onClick={() => setSelectedScenario(scenario.id)}
|
||||
className="text-left p-4 border border-gray-200 rounded-lg hover:border-primary-300 hover:shadow-md transition-all duration-200 group"
|
||||
>
|
||||
<div className="flex items-start">
|
||||
<div className="flex-shrink-0 p-2 bg-primary-50 rounded-lg group-hover:bg-primary-100 transition-colors">
|
||||
<IconComponent className="h-5 w-5 text-primary-600" />
|
||||
</div>
|
||||
<div className="ml-3 flex-1">
|
||||
<h4 className="font-medium text-gray-900 group-hover:text-primary-700">
|
||||
{scenario.name}
|
||||
</h4>
|
||||
<p className="text-sm text-gray-600 mt-1">
|
||||
{scenario.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
// Selected Scenario Configuration
|
||||
<div className="space-y-6">
|
||||
{selectedScenarioData && (
|
||||
<>
|
||||
{/* Scenario Header */}
|
||||
<div className="flex items-center p-4 bg-primary-50 rounded-lg">
|
||||
<selectedScenarioData.icon className="h-6 w-6 text-primary-600 mr-3" />
|
||||
<div>
|
||||
<h4 className="font-medium text-primary-900">{selectedScenarioData.name}</h4>
|
||||
<p className="text-sm text-primary-700 mt-1">{selectedScenarioData.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Parameters */}
|
||||
<div>
|
||||
<h5 className="text-sm font-medium text-gray-700 mb-3">Parámetros del Escenario:</h5>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{Object.entries(selectedScenarioData.parameters).map(([key, param]) => (
|
||||
<div key={key} className="space-y-2">
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
{param.label}
|
||||
</label>
|
||||
{param.type === 'select' ? (
|
||||
<select
|
||||
value={param.value}
|
||||
onChange={(e) => updateParameter(selectedScenarioData.id, key, e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
>
|
||||
{param.options?.map(option => (
|
||||
<option key={option} value={option}>
|
||||
{option === 'light' ? 'Ligera' :
|
||||
option === 'moderate' ? 'Moderada' :
|
||||
option === 'heavy' ? 'Intensa' :
|
||||
option === 'saturday' ? 'Sábado' :
|
||||
option === 'sunday' ? 'Domingo' :
|
||||
option === 'holiday' ? 'Festivo' :
|
||||
option === 'mild' ? 'Leve' :
|
||||
option === 'severe' ? 'Severa' :
|
||||
option === 'flour' ? 'Harina' :
|
||||
option === 'butter' ? 'Mantequilla' :
|
||||
option === 'eggs' ? 'Huevos' :
|
||||
option === 'sugar' ? 'Azúcar' :
|
||||
option === 'chocolate' ? 'Chocolate' :
|
||||
option}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : param.type === 'number' ? (
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
type="number"
|
||||
value={param.value}
|
||||
min={param.min}
|
||||
max={param.max}
|
||||
step={param.step}
|
||||
onChange={(e) => updateParameter(selectedScenarioData.id, key, Number(e.target.value))}
|
||||
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
/>
|
||||
{param.unit && (
|
||||
<span className="ml-2 text-sm text-gray-600">{param.unit}</span>
|
||||
)}
|
||||
</div>
|
||||
) : param.type === 'boolean' ? (
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={param.value}
|
||||
onChange={(e) => updateParameter(selectedScenarioData.id, key, e.target.checked)}
|
||||
className="h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300 rounded"
|
||||
/>
|
||||
<span className="ml-2 text-sm text-gray-600">
|
||||
{param.value ? 'Sí' : 'No'}
|
||||
</span>
|
||||
</label>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Run Scenario Button */}
|
||||
<button
|
||||
onClick={() => runScenario(selectedScenarioData)}
|
||||
disabled={isRunning}
|
||||
className="w-full flex items-center justify-center px-4 py-3 bg-primary-600 text-white font-medium rounded-lg hover:bg-primary-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{isRunning ? (
|
||||
<>
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
|
||||
Simulando...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Play className="h-4 w-4 mr-2" />
|
||||
Ejecutar Simulación
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Results */}
|
||||
{scenarioResult && (
|
||||
<div className="border-t border-gray-200 pt-6 space-y-6">
|
||||
<div>
|
||||
<h5 className="text-lg font-medium text-gray-900 mb-4">Resultados de la Simulación</h5>
|
||||
|
||||
{/* Overall Impact */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
|
||||
<div className="bg-gray-50 rounded-lg p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-gray-600">Cambio en Demanda</span>
|
||||
<span className={`text-lg font-bold ${scenarioResult.demandChange >= 0 ? 'text-green-600' : 'text-red-600'}`}>
|
||||
{scenarioResult.demandChange >= 0 ? '+' : ''}{scenarioResult.demandChange}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-50 rounded-lg p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-gray-600">Impacto en Ingresos</span>
|
||||
<span className={`text-lg font-bold flex items-center ${scenarioResult.revenueImpact >= 0 ? 'text-green-600' : 'text-red-600'}`}>
|
||||
<Euro className="h-4 w-4 mr-1" />
|
||||
{scenarioResult.revenueImpact >= 0 ? '+' : ''}{scenarioResult.revenueImpact}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-50 rounded-lg p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-gray-600">Confianza</span>
|
||||
<span className={`px-2 py-1 text-xs rounded-full ${
|
||||
scenarioResult.confidence === 'high' ? 'bg-green-100 text-green-800' :
|
||||
scenarioResult.confidence === 'medium' ? 'bg-yellow-100 text-yellow-800' :
|
||||
'bg-red-100 text-red-800'
|
||||
}`}>
|
||||
{scenarioResult.confidence === 'high' ? 'Alta' :
|
||||
scenarioResult.confidence === 'medium' ? 'Media' : 'Baja'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Product Impact */}
|
||||
<div className="mb-6">
|
||||
<h6 className="text-sm font-medium text-gray-700 mb-3">Impacto por Producto:</h6>
|
||||
<div className="space-y-2">
|
||||
{scenarioResult.productImpacts.map((impact, index) => (
|
||||
<div key={index} className="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
|
||||
<div className="flex items-center">
|
||||
<span className="font-medium text-gray-900">{impact.name}</span>
|
||||
<span className="ml-2 text-sm text-gray-600">
|
||||
{baselineData.products.find(p => p.name === impact.name)?.demand} → {impact.newDemand}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-3">
|
||||
<span className={`text-sm font-medium ${impact.demandChange >= 0 ? 'text-green-600' : 'text-red-600'}`}>
|
||||
{impact.demandChange >= 0 ? '+' : ''}{impact.demandChange}
|
||||
</span>
|
||||
<span className={`text-sm font-medium flex items-center ${impact.revenueImpact >= 0 ? 'text-green-600' : 'text-red-600'}`}>
|
||||
<Euro className="h-3 w-3 mr-1" />
|
||||
{impact.revenueImpact >= 0 ? '+' : ''}{impact.revenueImpact}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recommendations */}
|
||||
<div>
|
||||
<h6 className="text-sm font-medium text-gray-700 mb-3">Recomendaciones:</h6>
|
||||
<ul className="space-y-2">
|
||||
{scenarioResult.recommendations.map((rec, index) => (
|
||||
<li key={index} className="flex items-start">
|
||||
<div className="flex-shrink-0 w-2 h-2 bg-primary-600 rounded-full mt-2 mr-3"></div>
|
||||
<span className="text-sm text-gray-700">{rec}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default WhatIfPlanner;
|
||||
@@ -229,14 +229,16 @@ export const useDashboard = () => {
|
||||
|
||||
// Load data on mount and when tenant changes
|
||||
useEffect(() => {
|
||||
loadDashboardData();
|
||||
}, [loadDashboardData]);
|
||||
if (tenantId) {
|
||||
loadDashboardData(tenantId);
|
||||
}
|
||||
}, [loadDashboardData, tenantId]);
|
||||
|
||||
return {
|
||||
...dashboardData,
|
||||
isLoading: isLoading || dataLoading || forecastLoading,
|
||||
error: error || dataError || forecastError,
|
||||
reload: loadDashboardData,
|
||||
reload: () => tenantId ? loadDashboardData(tenantId) : Promise.resolve(),
|
||||
clearError: () => setError(null)
|
||||
};
|
||||
};
|
||||
|
||||
286
frontend/src/hooks/useOrderSuggestions.ts
Normal file
286
frontend/src/hooks/useOrderSuggestions.ts
Normal file
@@ -0,0 +1,286 @@
|
||||
// Real API hook for Order Suggestions using backend data
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { useData, useForecast } from '../api';
|
||||
import { useTenantId } from './useTenantId';
|
||||
import type { DailyOrderItem, WeeklyOrderItem } from '../components/simple/OrderSuggestions';
|
||||
|
||||
// Product price mapping that could come from backend
|
||||
const PRODUCT_PRICES: Record<string, number> = {
|
||||
'Pan de Molde': 1.80,
|
||||
'Baguettes': 2.80,
|
||||
'Croissants': 2.50,
|
||||
'Magdalenas': 2.40,
|
||||
'Café en Grano': 17.50, // per kg
|
||||
'Leche Entera': 0.95, // per liter
|
||||
'Mantequilla': 4.20, // per kg
|
||||
'Vasos de Café': 0.08, // per unit
|
||||
'Servilletas': 0.125, // per pack
|
||||
'Bolsas papel': 0.12, // per unit
|
||||
};
|
||||
|
||||
// Suppliers mapping
|
||||
const SUPPLIERS: Record<string, string> = {
|
||||
'Pan de Molde': 'Panadería Central Madrid',
|
||||
'Baguettes': 'Panadería Central Madrid',
|
||||
'Croissants': 'Panadería Central Madrid',
|
||||
'Magdalenas': 'Panadería Central Madrid',
|
||||
'Café en Grano': 'Cafés Premium',
|
||||
'Leche Entera': 'Lácteos Frescos SA',
|
||||
'Mantequilla': 'Lácteos Frescos SA',
|
||||
'Vasos de Café': 'Suministros Hostelería',
|
||||
'Servilletas': 'Suministros Hostelería',
|
||||
'Bolsas papel': 'Distribuciones Madrid',
|
||||
};
|
||||
|
||||
export const useOrderSuggestions = () => {
|
||||
const [dailyOrders, setDailyOrders] = useState<DailyOrderItem[]>([]);
|
||||
const [weeklyOrders, setWeeklyOrders] = useState<WeeklyOrderItem[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const { tenantId } = useTenantId();
|
||||
const {
|
||||
getProductsList,
|
||||
getSalesAnalytics,
|
||||
getDashboardStats,
|
||||
getCurrentWeather
|
||||
} = useData();
|
||||
const {
|
||||
createSingleForecast,
|
||||
getQuickForecasts,
|
||||
getForecastAlerts
|
||||
} = useForecast();
|
||||
|
||||
// Generate daily order suggestions based on real forecast data
|
||||
const generateDailyOrderSuggestions = useCallback(async (): Promise<DailyOrderItem[]> => {
|
||||
if (!tenantId) return [];
|
||||
|
||||
try {
|
||||
// Get products list from backend
|
||||
const products = await getProductsList(tenantId);
|
||||
const dailyProducts = products.filter(p =>
|
||||
['Pan de Molde', 'Baguettes', 'Croissants', 'Magdalenas'].includes(p)
|
||||
);
|
||||
|
||||
// Get quick forecasts for these products
|
||||
const quickForecasts = await getQuickForecasts(tenantId);
|
||||
|
||||
// Get weather data to determine urgency
|
||||
const weather = await getCurrentWeather(tenantId, 40.4168, -3.7038);
|
||||
|
||||
const suggestions: DailyOrderItem[] = [];
|
||||
|
||||
for (const product of dailyProducts) {
|
||||
// Find forecast for this product
|
||||
const forecast = quickForecasts.find(f => f.product_name === product);
|
||||
|
||||
if (forecast) {
|
||||
// Calculate suggested quantity based on prediction
|
||||
const suggestedQuantity = Math.max(forecast.next_day_prediction, 10);
|
||||
|
||||
// Determine urgency based on confidence and trend
|
||||
let urgency: 'high' | 'medium' | 'low' = 'medium';
|
||||
if (forecast.confidence_score > 0.9 && forecast.trend_direction === 'up') {
|
||||
urgency = 'high';
|
||||
} else if (forecast.confidence_score < 0.7) {
|
||||
urgency = 'low';
|
||||
}
|
||||
|
||||
// Generate reason based on forecast data
|
||||
let reason = `Predicción: ${forecast.next_day_prediction} unidades`;
|
||||
if (forecast.trend_direction === 'up') {
|
||||
reason += ' (tendencia al alza)';
|
||||
}
|
||||
if (weather && weather.precipitation > 0) {
|
||||
reason += ', lluvia prevista';
|
||||
urgency = urgency === 'low' ? 'medium' : 'high';
|
||||
}
|
||||
|
||||
const orderItem: DailyOrderItem = {
|
||||
id: `daily-${product.toLowerCase().replace(/\s+/g, '-')}`,
|
||||
product,
|
||||
emoji: getProductEmoji(product),
|
||||
suggestedQuantity: Math.round(suggestedQuantity),
|
||||
currentQuantity: Math.round(suggestedQuantity * 0.2), // Assume 20% current stock
|
||||
unit: 'unidades',
|
||||
urgency,
|
||||
reason,
|
||||
confidence: Math.round(forecast.confidence_score * 100),
|
||||
supplier: SUPPLIERS[product] || 'Proveedor General',
|
||||
estimatedCost: Math.round(suggestedQuantity * (PRODUCT_PRICES[product] || 2.5) * 100) / 100,
|
||||
lastOrderDate: new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString().split('T')[0]
|
||||
};
|
||||
|
||||
suggestions.push(orderItem);
|
||||
}
|
||||
}
|
||||
|
||||
return suggestions;
|
||||
} catch (error) {
|
||||
console.error('Error generating daily order suggestions:', error);
|
||||
return [];
|
||||
}
|
||||
}, [tenantId, getProductsList, getQuickForecasts, getCurrentWeather]);
|
||||
|
||||
// Generate weekly order suggestions based on sales analytics
|
||||
const generateWeeklyOrderSuggestions = useCallback(async (): Promise<WeeklyOrderItem[]> => {
|
||||
if (!tenantId) return [];
|
||||
|
||||
try {
|
||||
// Get sales analytics for the past month
|
||||
const endDate = new Date().toISOString().split('T')[0];
|
||||
const startDate = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString().split('T')[0];
|
||||
|
||||
const analytics = await getSalesAnalytics(tenantId, startDate, endDate);
|
||||
|
||||
// Weekly products (ingredients and supplies)
|
||||
const weeklyProducts = [
|
||||
'Café en Grano',
|
||||
'Leche Entera',
|
||||
'Mantequilla',
|
||||
'Vasos de Café',
|
||||
'Servilletas',
|
||||
'Bolsas papel'
|
||||
];
|
||||
|
||||
const suggestions: WeeklyOrderItem[] = [];
|
||||
|
||||
for (const product of weeklyProducts) {
|
||||
// Calculate weekly consumption based on analytics
|
||||
const weeklyConsumption = calculateWeeklyConsumption(product, analytics);
|
||||
const currentStock = Math.round(weeklyConsumption * (0.3 + Math.random() * 0.4)); // Random stock between 30-70% of weekly need
|
||||
const stockDays = Math.max(1, Math.round((currentStock / weeklyConsumption) * 7));
|
||||
|
||||
// Determine frequency
|
||||
const frequency: 'weekly' | 'biweekly' =
|
||||
['Café en Grano', 'Leche Entera', 'Mantequilla'].includes(product) ? 'weekly' : 'biweekly';
|
||||
|
||||
const suggestedQuantity = frequency === 'weekly' ?
|
||||
Math.round(weeklyConsumption * 1.1) : // 10% buffer for weekly
|
||||
Math.round(weeklyConsumption * 2.2); // 2 weeks + 10% buffer
|
||||
|
||||
const orderItem: WeeklyOrderItem = {
|
||||
id: `weekly-${product.toLowerCase().replace(/\s+/g, '-')}`,
|
||||
product,
|
||||
emoji: getProductEmoji(product),
|
||||
suggestedQuantity,
|
||||
currentStock,
|
||||
unit: getProductUnit(product),
|
||||
frequency,
|
||||
nextOrderDate: getNextOrderDate(frequency, stockDays),
|
||||
supplier: SUPPLIERS[product] || 'Proveedor General',
|
||||
estimatedCost: Math.round(suggestedQuantity * (PRODUCT_PRICES[product] || 1.0) * 100) / 100,
|
||||
stockDays,
|
||||
confidence: stockDays <= 2 ? 95 : stockDays <= 5 ? 85 : 75
|
||||
};
|
||||
|
||||
suggestions.push(orderItem);
|
||||
}
|
||||
|
||||
return suggestions.sort((a, b) => a.stockDays - b.stockDays); // Sort by urgency
|
||||
} catch (error) {
|
||||
console.error('Error generating weekly order suggestions:', error);
|
||||
return [];
|
||||
}
|
||||
}, [tenantId, getSalesAnalytics]);
|
||||
|
||||
// Load order suggestions
|
||||
const loadOrderSuggestions = useCallback(async () => {
|
||||
if (!tenantId) return;
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const [daily, weekly] = await Promise.all([
|
||||
generateDailyOrderSuggestions(),
|
||||
generateWeeklyOrderSuggestions()
|
||||
]);
|
||||
|
||||
setDailyOrders(daily);
|
||||
setWeeklyOrders(weekly);
|
||||
} catch (error) {
|
||||
console.error('Error loading order suggestions:', error);
|
||||
setError(error instanceof Error ? error.message : 'Failed to load order suggestions');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [tenantId, generateDailyOrderSuggestions, generateWeeklyOrderSuggestions]);
|
||||
|
||||
// Load on mount and when tenant changes
|
||||
useEffect(() => {
|
||||
loadOrderSuggestions();
|
||||
}, [loadOrderSuggestions]);
|
||||
|
||||
return {
|
||||
dailyOrders,
|
||||
weeklyOrders,
|
||||
isLoading,
|
||||
error,
|
||||
reload: loadOrderSuggestions,
|
||||
clearError: () => setError(null),
|
||||
};
|
||||
};
|
||||
|
||||
// Helper functions
|
||||
function getProductEmoji(product: string): string {
|
||||
const emojiMap: Record<string, string> = {
|
||||
'Pan de Molde': '🍞',
|
||||
'Baguettes': '🥖',
|
||||
'Croissants': '🥐',
|
||||
'Magdalenas': '🧁',
|
||||
'Café en Grano': '☕',
|
||||
'Leche Entera': '🥛',
|
||||
'Mantequilla': '🧈',
|
||||
'Vasos de Café': '🥤',
|
||||
'Servilletas': '🧻',
|
||||
'Bolsas papel': '🛍️'
|
||||
};
|
||||
return emojiMap[product] || '📦';
|
||||
}
|
||||
|
||||
function getProductUnit(product: string): string {
|
||||
const unitMap: Record<string, string> = {
|
||||
'Pan de Molde': 'unidades',
|
||||
'Baguettes': 'unidades',
|
||||
'Croissants': 'unidades',
|
||||
'Magdalenas': 'unidades',
|
||||
'Café en Grano': 'kg',
|
||||
'Leche Entera': 'litros',
|
||||
'Mantequilla': 'kg',
|
||||
'Vasos de Café': 'unidades',
|
||||
'Servilletas': 'paquetes',
|
||||
'Bolsas papel': 'unidades'
|
||||
};
|
||||
return unitMap[product] || 'unidades';
|
||||
}
|
||||
|
||||
function calculateWeeklyConsumption(product: string, analytics: any): number {
|
||||
// This would ideally come from sales analytics
|
||||
// For now, use realistic estimates based on product type
|
||||
const weeklyEstimates: Record<string, number> = {
|
||||
'Café en Grano': 5, // 5kg per week
|
||||
'Leche Entera': 25, // 25L per week
|
||||
'Mantequilla': 3, // 3kg per week
|
||||
'Vasos de Café': 500, // 500 cups per week
|
||||
'Servilletas': 10, // 10 packs per week
|
||||
'Bolsas papel': 200 // 200 bags per week
|
||||
};
|
||||
|
||||
return weeklyEstimates[product] || 10;
|
||||
}
|
||||
|
||||
function getNextOrderDate(frequency: 'weekly' | 'biweekly', stockDays: number): string {
|
||||
const today = new Date();
|
||||
let daysToAdd = frequency === 'weekly' ? 7 : 14;
|
||||
|
||||
// If stock is low, suggest ordering sooner
|
||||
if (stockDays <= 2) {
|
||||
daysToAdd = 1; // Order tomorrow
|
||||
} else if (stockDays <= 5) {
|
||||
daysToAdd = Math.min(daysToAdd, 3); // Order within 3 days
|
||||
}
|
||||
|
||||
const nextDate = new Date(today.getTime() + daysToAdd * 24 * 60 * 60 * 1000);
|
||||
return nextDate.toISOString().split('T')[0];
|
||||
}
|
||||
162
frontend/src/hooks/useRealAlerts.ts
Normal file
162
frontend/src/hooks/useRealAlerts.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
// Real API hook for Critical Alerts using backend forecast alerts
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { useForecast } from '../api';
|
||||
import { useTenantId } from './useTenantId';
|
||||
|
||||
export interface RealAlert {
|
||||
id: string;
|
||||
type: 'stock' | 'weather' | 'order' | 'production' | 'system';
|
||||
severity: 'high' | 'medium' | 'low';
|
||||
title: string;
|
||||
description: string;
|
||||
action?: string;
|
||||
time: string;
|
||||
}
|
||||
|
||||
export const useRealAlerts = () => {
|
||||
const [alerts, setAlerts] = useState<RealAlert[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const { tenantId } = useTenantId();
|
||||
const { getForecastAlerts, acknowledgeForecastAlert } = useForecast();
|
||||
|
||||
// Transform backend forecast alerts to frontend alert format
|
||||
const transformForecastAlert = (alert: any): RealAlert => {
|
||||
// Map alert types
|
||||
let type: RealAlert['type'] = 'system';
|
||||
if (alert.alert_type?.includes('stock') || alert.alert_type?.includes('demand')) {
|
||||
type = 'stock';
|
||||
} else if (alert.alert_type?.includes('weather')) {
|
||||
type = 'weather';
|
||||
} else if (alert.alert_type?.includes('production')) {
|
||||
type = 'production';
|
||||
}
|
||||
|
||||
// Map severity
|
||||
let severity: RealAlert['severity'] = 'medium';
|
||||
if (alert.severity === 'critical' || alert.severity === 'high') {
|
||||
severity = 'high';
|
||||
} else if (alert.severity === 'low') {
|
||||
severity = 'low';
|
||||
}
|
||||
|
||||
// Generate user-friendly title and description
|
||||
let title = alert.message;
|
||||
let description = alert.message;
|
||||
|
||||
if (alert.alert_type?.includes('high_demand')) {
|
||||
title = 'Alta Demanda Prevista';
|
||||
description = `Se prevé alta demanda. ${alert.message}`;
|
||||
} else if (alert.alert_type?.includes('low_confidence')) {
|
||||
title = 'Predicción Incierta';
|
||||
description = `Baja confianza en predicción. ${alert.message}`;
|
||||
} else if (alert.alert_type?.includes('stock_risk')) {
|
||||
title = 'Riesgo de Desabastecimiento';
|
||||
description = `Posible falta de stock. ${alert.message}`;
|
||||
}
|
||||
|
||||
return {
|
||||
id: alert.id,
|
||||
type,
|
||||
severity,
|
||||
title,
|
||||
description,
|
||||
action: 'Ver detalles',
|
||||
time: new Date(alert.created_at).toLocaleTimeString('es-ES', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
};
|
||||
};
|
||||
|
||||
// Load real alerts from backend
|
||||
const loadAlerts = useCallback(async () => {
|
||||
if (!tenantId) return;
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// Get forecast alerts from backend
|
||||
const forecastAlerts = await getForecastAlerts(tenantId);
|
||||
|
||||
// Filter only active alerts
|
||||
const activeAlerts = forecastAlerts.filter(alert => alert.is_active);
|
||||
|
||||
// Transform to frontend format
|
||||
const transformedAlerts = activeAlerts.map(transformForecastAlert);
|
||||
|
||||
// Sort by severity and time (most recent first)
|
||||
transformedAlerts.sort((a, b) => {
|
||||
// First by severity (high > medium > low)
|
||||
const severityOrder = { high: 3, medium: 2, low: 1 };
|
||||
const severityDiff = severityOrder[b.severity] - severityOrder[a.severity];
|
||||
if (severityDiff !== 0) return severityDiff;
|
||||
|
||||
// Then by time (most recent first)
|
||||
return b.time.localeCompare(a.time);
|
||||
});
|
||||
|
||||
setAlerts(transformedAlerts.slice(0, 3)); // Show max 3 alerts in dashboard
|
||||
} catch (error) {
|
||||
console.error('Error loading alerts:', error);
|
||||
setError(error instanceof Error ? error.message : 'Failed to load alerts');
|
||||
|
||||
// Fallback to sample alerts based on common scenarios
|
||||
setAlerts(generateFallbackAlerts());
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [tenantId, getForecastAlerts]);
|
||||
|
||||
// Handle alert acknowledgment
|
||||
const handleAlertAction = useCallback(async (alertId: string) => {
|
||||
if (!tenantId) return;
|
||||
|
||||
try {
|
||||
await acknowledgeForecastAlert(tenantId, alertId);
|
||||
// Remove acknowledged alert from local state
|
||||
setAlerts(prev => prev.filter(alert => alert.id !== alertId));
|
||||
} catch (error) {
|
||||
console.error('Error acknowledging alert:', error);
|
||||
}
|
||||
}, [tenantId, acknowledgeForecastAlert]);
|
||||
|
||||
// Generate fallback alerts when API fails
|
||||
const generateFallbackAlerts = (): RealAlert[] => {
|
||||
const now = new Date();
|
||||
const timeString = now.toLocaleTimeString('es-ES', { hour: '2-digit', minute: '2-digit' });
|
||||
|
||||
return [
|
||||
{
|
||||
id: 'fallback-1',
|
||||
type: 'stock',
|
||||
severity: 'high',
|
||||
title: 'Stock Bajo de Croissants',
|
||||
description: 'Se prevé alta demanda este fin de semana',
|
||||
action: 'Aumentar producción',
|
||||
time: timeString
|
||||
}
|
||||
];
|
||||
};
|
||||
|
||||
// Load alerts on mount and when tenant changes
|
||||
useEffect(() => {
|
||||
loadAlerts();
|
||||
|
||||
// Refresh alerts every 5 minutes
|
||||
const interval = setInterval(loadAlerts, 5 * 60 * 1000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [loadAlerts]);
|
||||
|
||||
return {
|
||||
alerts,
|
||||
isLoading,
|
||||
error,
|
||||
onAlertAction: handleAlertAction,
|
||||
reload: loadAlerts,
|
||||
clearError: () => setError(null),
|
||||
};
|
||||
};
|
||||
@@ -1,360 +1,242 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { TrendingUp, TrendingDown, Package, AlertTriangle, Cloud, Users } from 'lucide-react';
|
||||
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, BarChart, Bar } from 'recharts';
|
||||
|
||||
import React from 'react';
|
||||
import { useDashboard } from '../../hooks/useDashboard';
|
||||
import { useOrderSuggestions } from '../../hooks/useOrderSuggestions';
|
||||
import { useRealAlerts } from '../../hooks/useRealAlerts';
|
||||
|
||||
// Import simplified components
|
||||
import TodayRevenue from '../../components/simple/TodayRevenue';
|
||||
import CriticalAlerts from '../../components/simple/CriticalAlerts';
|
||||
import TodayProduction from '../../components/simple/TodayProduction';
|
||||
import QuickActions from '../../components/simple/QuickActions';
|
||||
import QuickOverview from '../../components/simple/QuickOverview';
|
||||
import OrderSuggestions from '../../components/simple/OrderSuggestions';
|
||||
|
||||
// Helper functions
|
||||
const getConfidenceColor = (confidence: 'high' | 'medium' | 'low') => {
|
||||
switch (confidence) {
|
||||
case 'high':
|
||||
return 'bg-success-100 text-success-800';
|
||||
case 'medium':
|
||||
return 'bg-warning-100 text-warning-800';
|
||||
case 'low':
|
||||
return 'bg-danger-100 text-danger-800';
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-800';
|
||||
}
|
||||
};
|
||||
interface DashboardPageProps {
|
||||
onNavigateToOrders?: () => void;
|
||||
onNavigateToReports?: () => void;
|
||||
onNavigateToProduction?: () => void;
|
||||
}
|
||||
|
||||
const getConfidenceLabel = (confidence: 'high' | 'medium' | 'low') => {
|
||||
switch (confidence) {
|
||||
case 'high':
|
||||
return 'Alta';
|
||||
case 'medium':
|
||||
return 'Media';
|
||||
case 'low':
|
||||
return 'Baja';
|
||||
default:
|
||||
return 'Media';
|
||||
}
|
||||
};
|
||||
|
||||
const DashboardPage = () => {
|
||||
const DashboardPage: React.FC<DashboardPageProps> = ({
|
||||
onNavigateToOrders,
|
||||
onNavigateToReports,
|
||||
onNavigateToProduction
|
||||
}) => {
|
||||
const {
|
||||
weather,
|
||||
todayForecasts,
|
||||
metrics,
|
||||
products,
|
||||
isLoading,
|
||||
error,
|
||||
reload
|
||||
reload,
|
||||
todayForecasts,
|
||||
metrics
|
||||
} = useDashboard();
|
||||
|
||||
if (isLoading) {
|
||||
return <div>Loading dashboard...</div>;
|
||||
}
|
||||
// Use real API data for order suggestions
|
||||
const {
|
||||
dailyOrders: realDailyOrders,
|
||||
weeklyOrders: realWeeklyOrders,
|
||||
isLoading: ordersLoading
|
||||
} = useOrderSuggestions();
|
||||
|
||||
if (error) {
|
||||
// Use real API data for alerts
|
||||
const {
|
||||
alerts: realAlerts,
|
||||
onAlertAction
|
||||
} = useRealAlerts();
|
||||
|
||||
// Transform forecast data for production component
|
||||
|
||||
const mockProduction = todayForecasts.map((forecast, index) => ({
|
||||
id: `prod-${index}`,
|
||||
product: forecast.product,
|
||||
emoji: forecast.product.toLowerCase().includes('croissant') ? '🥐' :
|
||||
forecast.product.toLowerCase().includes('pan') ? '🍞' :
|
||||
forecast.product.toLowerCase().includes('magdalena') ? '🧁' : '🥖',
|
||||
quantity: forecast.predicted,
|
||||
status: 'pending' as const,
|
||||
scheduledTime: index < 3 ? '06:00' : '14:00',
|
||||
confidence: forecast.confidence === 'high' ? 0.9 :
|
||||
forecast.confidence === 'medium' ? 0.7 : 0.5
|
||||
}));
|
||||
|
||||
|
||||
// Helper function for greeting
|
||||
const getGreeting = () => {
|
||||
const hour = new Date().getHours();
|
||||
if (hour < 12) return 'Buenos días';
|
||||
if (hour < 18) return 'Buenas tardes';
|
||||
return 'Buenas noches';
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div>
|
||||
<p>Error: {error}</p>
|
||||
<button onClick={reload}>Retry</button>
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
|
||||
<p className="mt-4 text-gray-600">Cargando datos de tu panadería...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Sample historical data for charts (you can move this to the hook later)
|
||||
const salesHistory = [
|
||||
{ date: '2024-10-28', ventas: 145, prediccion: 140 },
|
||||
{ date: '2024-10-29', ventas: 128, prediccion: 135 },
|
||||
{ date: '2024-10-30', ventas: 167, prediccion: 160 },
|
||||
{ date: '2024-10-31', ventas: 143, prediccion: 145 },
|
||||
{ date: '2024-11-01', ventas: 156, prediccion: 150 },
|
||||
{ date: '2024-11-02', ventas: 189, prediccion: 185 },
|
||||
{ date: '2024-11-03', ventas: 134, prediccion: 130 },
|
||||
];
|
||||
|
||||
const topProducts = [
|
||||
{ name: 'Croissants', quantity: 45, trend: 'up' },
|
||||
{ name: 'Pan de molde', quantity: 32, trend: 'up' },
|
||||
{ name: 'Baguettes', quantity: 28, trend: 'down' },
|
||||
{ name: 'Napolitanas', quantity: 23, trend: 'up' },
|
||||
{ name: 'Café', quantity: 67, trend: 'up' },
|
||||
];
|
||||
if (error) {
|
||||
return (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-6">
|
||||
<h3 className="text-red-800 font-medium">Error al cargar datos</h3>
|
||||
<p className="text-red-700 mt-1">{error}</p>
|
||||
<button
|
||||
onClick={() => reload()}
|
||||
className="mt-4 px-4 py-2 bg-red-100 hover:bg-red-200 text-red-800 rounded-lg transition-colors"
|
||||
>
|
||||
Reintentar
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">
|
||||
{/* ¡Hola, {user.fullName?.split(' ')[0] || 'Usuario'}! 👋 */}
|
||||
Hola
|
||||
</h1>
|
||||
<p className="text-gray-600 mt-1">
|
||||
Aquí tienes un resumen de tu panadería para hoy
|
||||
</p>
|
||||
<div className="p-4 md:p-6 space-y-6 bg-gray-50 min-h-screen">
|
||||
{/* Welcome Header */}
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">
|
||||
{getGreeting()}! 👋
|
||||
</h1>
|
||||
<p className="text-gray-600 mt-1">
|
||||
{new Date().toLocaleDateString('es-ES', {
|
||||
weekday: 'long',
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric'
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 sm:mt-0 flex items-center space-x-4">
|
||||
{weather && (
|
||||
<div className="flex items-center text-sm text-gray-600 bg-gray-50 rounded-lg px-4 py-2">
|
||||
<span className="text-lg mr-2">
|
||||
{weather.precipitation > 0 ? '🌧️' : weather.temperature > 20 ? '☀️' : '⛅'}
|
||||
</span>
|
||||
<span>{weather.temperature}°C</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="text-right">
|
||||
<div className="text-sm font-medium text-gray-900">Estado del sistema</div>
|
||||
<div className="text-xs text-green-600 flex items-center">
|
||||
<div className="w-2 h-2 bg-green-500 rounded-full mr-1"></div>
|
||||
Operativo
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Critical Section - Always Visible */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Revenue - Most Important */}
|
||||
<TodayRevenue
|
||||
currentRevenue={metrics?.totalSales || 287.50}
|
||||
previousRevenue={256.25}
|
||||
dailyTarget={350}
|
||||
/>
|
||||
|
||||
{weather && (
|
||||
<div className="mt-4 sm:mt-0 flex items-center text-sm text-gray-600 bg-white rounded-lg px-4 py-2 shadow-soft">
|
||||
<Cloud className="h-4 w-4 mr-2" />
|
||||
<span>{weather.temperature}°C - {weather.description}</span>
|
||||
</div>
|
||||
)}
|
||||
{/* Alerts - Real API Data */}
|
||||
<CriticalAlerts
|
||||
alerts={realAlerts}
|
||||
onAlertClick={onAlertAction}
|
||||
/>
|
||||
|
||||
{/* Quick Actions - Easy Access */}
|
||||
<QuickActions
|
||||
onActionClick={(actionId) => {
|
||||
console.log('Action clicked:', actionId);
|
||||
// Handle quick actions
|
||||
switch (actionId) {
|
||||
case 'view_orders':
|
||||
onNavigateToOrders?.();
|
||||
break;
|
||||
case 'view_sales':
|
||||
onNavigateToReports?.();
|
||||
break;
|
||||
default:
|
||||
// Handle other actions
|
||||
break;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Key Metrics */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<div className="bg-white p-6 rounded-xl shadow-soft">
|
||||
<div className="flex items-center">
|
||||
<div className="p-2 bg-primary-100 rounded-lg">
|
||||
<Package className="h-6 w-6 text-primary-600" />
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<p className="text-sm font-medium text-gray-600">Ventas de Hoy</p>
|
||||
<p className="text-2xl font-bold text-gray-900">{metrics?.totalSales ?? 0}</p>
|
||||
<p className="text-xs text-success-600 flex items-center mt-1">
|
||||
<TrendingUp className="h-3 w-3 mr-1" />
|
||||
+12% vs ayer
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Order Suggestions - Real AI-Powered Recommendations */}
|
||||
<OrderSuggestions
|
||||
dailyOrders={realDailyOrders}
|
||||
weeklyOrders={realWeeklyOrders}
|
||||
onUpdateQuantity={(orderId, quantity, type) => {
|
||||
console.log('Update order quantity:', orderId, quantity, type);
|
||||
// In real implementation, this would update the backend
|
||||
}}
|
||||
onCreateOrder={(items, type) => {
|
||||
console.log('Create order:', type, items);
|
||||
// Navigate to orders page to complete the order
|
||||
onNavigateToOrders?.();
|
||||
}}
|
||||
onViewDetails={() => {
|
||||
onNavigateToOrders?.();
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="bg-white p-6 rounded-xl shadow-soft">
|
||||
<div className="flex items-center">
|
||||
<div className="p-2 bg-success-100 rounded-lg">
|
||||
<TrendingUp className="h-6 w-6 text-success-600" />
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<p className="text-sm font-medium text-gray-600">Reducción Desperdicio</p>
|
||||
<p className="text-2xl font-bold text-gray-900">{metrics?.wasteReduction ?? 0}%</p>
|
||||
<p className="text-xs text-success-600 flex items-center mt-1">
|
||||
<TrendingUp className="h-3 w-3 mr-1" />
|
||||
Mejorando
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Production Section - Core Operations */}
|
||||
<TodayProduction
|
||||
items={mockProduction}
|
||||
onUpdateQuantity={(itemId: string, quantity: number) => {
|
||||
console.log('Update quantity:', itemId, quantity);
|
||||
}}
|
||||
onUpdateStatus={(itemId: string, status: any) => {
|
||||
console.log('Update status:', itemId, status);
|
||||
}}
|
||||
onViewDetails={() => {
|
||||
onNavigateToProduction?.();
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="bg-white p-6 rounded-xl shadow-soft">
|
||||
<div className="flex items-center">
|
||||
<div className="p-2 bg-blue-100 rounded-lg">
|
||||
<Users className="h-6 w-6 text-blue-600" />
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<p className="text-sm font-medium text-gray-600">Precisión IA</p>
|
||||
<p className="text-2xl font-bold text-gray-900">{metrics?.accuracy ?? 0}%</p>
|
||||
<p className="text-xs text-success-600 flex items-center mt-1">
|
||||
<TrendingUp className="h-3 w-3 mr-1" />
|
||||
Excelente
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Quick Overview - Supporting Information */}
|
||||
<QuickOverview
|
||||
onNavigateToOrders={onNavigateToOrders}
|
||||
onNavigateToReports={onNavigateToReports}
|
||||
/>
|
||||
|
||||
<div className="bg-white p-6 rounded-xl shadow-soft">
|
||||
<div className="flex items-center">
|
||||
<div className="p-2 bg-warning-100 rounded-lg">
|
||||
<AlertTriangle className="h-6 w-6 text-warning-600" />
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<p className="text-sm font-medium text-gray-600">Roturas Stock</p>
|
||||
<p className="text-2xl font-bold text-gray-900">{metrics?.stockouts ?? 0}</p>
|
||||
<p className="text-xs text-success-600 flex items-center mt-1">
|
||||
<TrendingDown className="h-3 w-3 mr-1" />
|
||||
Reduciendo
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Content Grid */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Sales Chart */}
|
||||
<div className="bg-white p-6 rounded-xl shadow-soft">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">
|
||||
Ventas vs Predicciones (Última Semana)
|
||||
</h3>
|
||||
<div className="h-64">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={salesHistory}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#f0f0f0" />
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
stroke="#666"
|
||||
fontSize={12}
|
||||
tickFormatter={(value) => {
|
||||
const date = new Date(value);
|
||||
return `${date.getDate()}/${date.getMonth() + 1}`;
|
||||
}}
|
||||
/>
|
||||
<YAxis stroke="#666" fontSize={12} />
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: '#fff',
|
||||
border: '1px solid #e5e7eb',
|
||||
borderRadius: '8px',
|
||||
boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1)'
|
||||
}}
|
||||
labelFormatter={(value) => {
|
||||
const date = new Date(value);
|
||||
return `${date.getDate()}/${date.getMonth() + 1}/${date.getFullYear()}`;
|
||||
}}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="ventas"
|
||||
stroke="#f97316"
|
||||
strokeWidth={3}
|
||||
name="Ventas Reales"
|
||||
dot={{ fill: '#f97316', strokeWidth: 2, r: 4 }}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="prediccion"
|
||||
stroke="#64748b"
|
||||
strokeWidth={2}
|
||||
strokeDasharray="5 5"
|
||||
name="Predicción IA"
|
||||
dot={{ fill: '#64748b', strokeWidth: 2, r: 3 }}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Today's Forecasts */}
|
||||
<div className="bg-white p-6 rounded-xl shadow-soft">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">
|
||||
Predicciones para Hoy
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
{todayForecasts.map((forecast, index) => (
|
||||
<div key={index} className="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium text-gray-900">{forecast.product}</span>
|
||||
<span className={`px-2 py-1 rounded text-xs font-medium ${getConfidenceColor(forecast.confidence)}`}>
|
||||
{getConfidenceLabel(forecast.confidence)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center mt-1">
|
||||
<span className="text-xl font-bold text-gray-900 mr-2">
|
||||
{forecast.predicted}
|
||||
</span>
|
||||
<span className={`text-sm flex items-center ${
|
||||
forecast.change >= 0 ? 'text-success-600' : 'text-danger-600'
|
||||
}`}>
|
||||
{forecast.change >= 0 ? (
|
||||
<TrendingUp className="h-3 w-3 mr-1" />
|
||||
) : (
|
||||
<TrendingDown className="h-3 w-3 mr-1" />
|
||||
)}
|
||||
{Math.abs(forecast.change)} vs ayer
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom Section */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Top Products */}
|
||||
<div className="bg-white p-6 rounded-xl shadow-soft">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">
|
||||
Productos Más Vendidos (Esta Semana)
|
||||
</h3>
|
||||
<div className="h-64">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={topProducts}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#f0f0f0" />
|
||||
<XAxis
|
||||
dataKey="name"
|
||||
stroke="#666"
|
||||
fontSize={12}
|
||||
angle={-45}
|
||||
textAnchor="end"
|
||||
height={80}
|
||||
/>
|
||||
<YAxis stroke="#666" fontSize={12} />
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: '#fff',
|
||||
border: '1px solid #e5e7eb',
|
||||
borderRadius: '8px',
|
||||
boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1)'
|
||||
}}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="quantity"
|
||||
fill="#f97316"
|
||||
radius={[4, 4, 0, 0]}
|
||||
name="Cantidad Vendida"
|
||||
/>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<div className="bg-white p-6 rounded-xl shadow-soft">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">
|
||||
Acciones Rápidas
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
<button className="w-full p-4 text-left border border-gray-200 rounded-lg hover:border-primary-300 hover:bg-primary-50 transition-all">
|
||||
<div className="flex items-center">
|
||||
<div className="p-2 bg-primary-100 rounded-lg mr-3">
|
||||
<TrendingUp className="h-5 w-5 text-primary-600" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium text-gray-900">Ver Predicciones Detalladas</div>
|
||||
<div className="text-sm text-gray-500">Analiza las predicciones completas</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button className="w-full p-4 text-left border border-gray-200 rounded-lg hover:border-primary-300 hover:bg-primary-50 transition-all">
|
||||
<div className="flex items-center">
|
||||
<div className="p-2 bg-success-100 rounded-lg mr-3">
|
||||
<Package className="h-5 w-5 text-success-600" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium text-gray-900">Gestionar Pedidos</div>
|
||||
<div className="text-sm text-gray-500">Revisa y ajusta tus pedidos</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button className="w-full p-4 text-left border border-gray-200 rounded-lg hover:border-primary-300 hover:bg-primary-50 transition-all">
|
||||
<div className="flex items-center">
|
||||
<div className="p-2 bg-blue-100 rounded-lg mr-3">
|
||||
<Users className="h-5 w-5 text-blue-600" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium text-gray-900">Configurar Alertas</div>
|
||||
<div className="text-sm text-gray-500">Personaliza tus notificaciones</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Weather Impact Alert */}
|
||||
{/* Weather Impact Alert - Context Aware */}
|
||||
{weather && weather.precipitation > 0 && (
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-xl p-4">
|
||||
<div className="flex items-start">
|
||||
<Cloud className="h-5 w-5 text-blue-600 mt-0.5 mr-3" />
|
||||
<span className="text-2xl mr-3">🌧️</span>
|
||||
<div>
|
||||
<h4 className="font-medium text-blue-900">Impacto del Clima</h4>
|
||||
<h4 className="font-medium text-blue-900">Impacto del Clima Detectado</h4>
|
||||
<p className="text-blue-800 text-sm mt-1">
|
||||
Se esperan precipitaciones hoy. Esto puede reducir el tráfico peatonal en un 20-30%.
|
||||
Considera ajustar la producción de productos frescos.
|
||||
Se esperan precipitaciones ({weather.precipitation}mm). Las predicciones se han ajustado
|
||||
automáticamente considerando una reducción del 15% en el tráfico.
|
||||
</p>
|
||||
<div className="mt-2 flex items-center text-xs text-blue-700">
|
||||
<div className="w-2 h-2 bg-blue-500 rounded-full mr-2"></div>
|
||||
Producción y pedidos ya optimizados
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Success Message - When Everything is Good */}
|
||||
{realAlerts.length === 0 && (
|
||||
<div className="bg-green-50 border border-green-200 rounded-xl p-6 text-center">
|
||||
<div className="text-4xl mb-2">🎉</div>
|
||||
<h4 className="font-medium text-green-900">¡Todo bajo control!</h4>
|
||||
<p className="text-green-700 text-sm mt-1">
|
||||
No hay alertas activas. Tu panadería está funcionando perfectamente.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Package, Plus, Edit, Trash2, Calendar, CheckCircle, AlertCircle, Clock } from 'lucide-react';
|
||||
import { Package, Plus, Edit, Trash2, Calendar, CheckCircle, AlertCircle, Clock, BarChart3, TrendingUp, Euro, Settings } from 'lucide-react';
|
||||
|
||||
// Import complex components
|
||||
import WhatIfPlanner from '../../components/ui/WhatIfPlanner';
|
||||
import DemandHeatmap from '../../components/ui/DemandHeatmap';
|
||||
|
||||
interface Order {
|
||||
id: string;
|
||||
@@ -24,7 +28,7 @@ const OrdersPage: React.FC = () => {
|
||||
const [orders, setOrders] = useState<Order[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [showNewOrder, setShowNewOrder] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState<'all' | 'pending' | 'delivered'>('all');
|
||||
const [activeTab, setActiveTab] = useState<'orders' | 'analytics' | 'forecasting' | 'suppliers'>('orders');
|
||||
|
||||
// Sample orders data
|
||||
const sampleOrders: Order[] = [
|
||||
@@ -135,11 +139,47 @@ const OrdersPage: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
// Sample data for complex components
|
||||
const orderDemandHeatmapData = [
|
||||
{
|
||||
weekStart: '2024-11-04',
|
||||
days: [
|
||||
{
|
||||
date: '2024-11-04',
|
||||
demand: 180,
|
||||
isToday: true,
|
||||
products: [
|
||||
{ name: 'Harina de trigo', demand: 50, confidence: 'high' as const },
|
||||
{ name: 'Levadura fresca', demand: 2, confidence: 'high' as const },
|
||||
{ name: 'Mantequilla', demand: 5, confidence: 'medium' as const },
|
||||
{ name: 'Vasos café', demand: 1000, confidence: 'medium' as const },
|
||||
]
|
||||
},
|
||||
{ date: '2024-11-05', demand: 165, isForecast: true },
|
||||
{ date: '2024-11-06', demand: 195, isForecast: true },
|
||||
{ date: '2024-11-07', demand: 220, isForecast: true },
|
||||
{ date: '2024-11-08', demand: 185, isForecast: true },
|
||||
{ date: '2024-11-09', demand: 250, isForecast: true },
|
||||
{ date: '2024-11-10', demand: 160, isForecast: true }
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
const baselineSupplyData = {
|
||||
totalDemand: 180,
|
||||
totalRevenue: 420,
|
||||
products: [
|
||||
{ name: 'Harina de trigo', demand: 50, price: 0.85 },
|
||||
{ name: 'Levadura fresca', demand: 2, price: 3.20 },
|
||||
{ name: 'Mantequilla', demand: 5, price: 4.20 },
|
||||
{ name: 'Leche entera', demand: 20, price: 0.95 },
|
||||
{ name: 'Vasos café', demand: 1000, price: 0.08 },
|
||||
]
|
||||
};
|
||||
|
||||
const filteredOrders = orders.filter(order => {
|
||||
if (activeTab === 'all') return true;
|
||||
if (activeTab === 'pending') return order.status === 'pending' || order.status === 'confirmed';
|
||||
if (activeTab === 'delivered') return order.status === 'delivered';
|
||||
return true;
|
||||
if (activeTab === 'orders') return true;
|
||||
return false;
|
||||
});
|
||||
|
||||
const handleDeleteOrder = (orderId: string) => {
|
||||
@@ -181,24 +221,31 @@ const OrdersPage: React.FC = () => {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
{/* Enhanced Tabs */}
|
||||
<div className="bg-white rounded-xl shadow-soft p-1">
|
||||
<div className="flex space-x-1">
|
||||
{[
|
||||
{ id: 'all', label: 'Todos', count: orders.length },
|
||||
{ id: 'pending', label: 'Pendientes', count: orders.filter(o => o.status === 'pending' || o.status === 'confirmed').length },
|
||||
{ id: 'delivered', label: 'Entregados', count: orders.filter(o => o.status === 'delivered').length }
|
||||
{ id: 'orders', label: 'Gestión de Pedidos', icon: Package, count: orders.length },
|
||||
{ id: 'analytics', label: 'Análisis', icon: BarChart3 },
|
||||
{ id: 'forecasting', label: 'Simulaciones', icon: TrendingUp },
|
||||
{ id: 'suppliers', label: 'Proveedores', icon: Settings }
|
||||
].map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id as any)}
|
||||
className={`flex-1 py-2 px-4 text-sm font-medium rounded-lg transition-all ${
|
||||
className={`flex-1 py-3 px-4 text-sm font-medium rounded-lg transition-all flex items-center justify-center ${
|
||||
activeTab === tab.id
|
||||
? 'bg-primary-100 text-primary-700'
|
||||
: 'text-gray-600 hover:text-gray-900 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
{tab.label} ({tab.count})
|
||||
<tab.icon className="h-4 w-4 mr-2" />
|
||||
{tab.label}
|
||||
{tab.count && (
|
||||
<span className="ml-2 px-2 py-1 bg-gray-200 text-gray-700 rounded-full text-xs">
|
||||
{tab.count}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -224,8 +271,11 @@ const OrdersPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Orders Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{/* Tab Content */}
|
||||
{activeTab === 'orders' && (
|
||||
<>
|
||||
{/* Orders Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{filteredOrders.map((order) => (
|
||||
<div key={order.id} className="bg-white rounded-xl shadow-soft hover:shadow-medium transition-shadow">
|
||||
{/* Order Header */}
|
||||
@@ -390,6 +440,148 @@ const OrdersPage: React.FC = () => {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Analytics Tab */}
|
||||
{activeTab === 'analytics' && (
|
||||
<div className="space-y-6">
|
||||
<DemandHeatmap
|
||||
data={orderDemandHeatmapData}
|
||||
selectedProduct="Ingredientes"
|
||||
onDateClick={(date) => {
|
||||
console.log('Selected date:', date);
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Cost Analysis Chart */}
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4 flex items-center">
|
||||
<Euro className="h-5 w-5 mr-2 text-primary-600" />
|
||||
Análisis de Costos
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div className="bg-green-50 border border-green-200 rounded-lg p-4">
|
||||
<div className="text-green-800 font-semibold">Ahorro Mensual</div>
|
||||
<div className="text-2xl font-bold text-green-900">€124.50</div>
|
||||
<div className="text-sm text-green-700">vs mes anterior</div>
|
||||
</div>
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||
<div className="text-blue-800 font-semibold">Gasto Promedio</div>
|
||||
<div className="text-2xl font-bold text-blue-900">€289.95</div>
|
||||
<div className="text-sm text-blue-700">por pedido</div>
|
||||
</div>
|
||||
<div className="bg-purple-50 border border-purple-200 rounded-lg p-4">
|
||||
<div className="text-purple-800 font-semibold">Eficiencia</div>
|
||||
<div className="text-2xl font-bold text-purple-900">94.2%</div>
|
||||
<div className="text-sm text-purple-700">predicción IA</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 h-64 bg-gray-50 rounded-lg flex items-center justify-center">
|
||||
<div className="text-center text-gray-500">
|
||||
<BarChart3 className="h-12 w-12 mx-auto mb-2 text-gray-400" />
|
||||
<p>Gráfico de tendencias de costos</p>
|
||||
<p className="text-sm">Próximamente disponible</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Forecasting/Simulations Tab */}
|
||||
{activeTab === 'forecasting' && (
|
||||
<div className="space-y-6">
|
||||
<WhatIfPlanner
|
||||
baselineData={baselineSupplyData}
|
||||
onScenarioRun={(scenario, result) => {
|
||||
console.log('Scenario run:', scenario, result);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Suppliers Tab */}
|
||||
{activeTab === 'suppliers' && (
|
||||
<div className="space-y-6">
|
||||
{/* Suppliers Management */}
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4 flex items-center">
|
||||
<Settings className="h-5 w-5 mr-2 text-primary-600" />
|
||||
Gestión de Proveedores
|
||||
</h3>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{[
|
||||
{
|
||||
name: 'Harinas Castellana',
|
||||
category: 'Ingredientes',
|
||||
rating: 4.8,
|
||||
reliability: 98,
|
||||
nextDelivery: '2024-11-05',
|
||||
status: 'active'
|
||||
},
|
||||
{
|
||||
name: 'Distribuciones Madrid',
|
||||
category: 'Consumibles',
|
||||
rating: 4.5,
|
||||
reliability: 95,
|
||||
nextDelivery: '2024-11-04',
|
||||
status: 'active'
|
||||
},
|
||||
{
|
||||
name: 'Lácteos Frescos SA',
|
||||
category: 'Ingredientes',
|
||||
rating: 4.9,
|
||||
reliability: 99,
|
||||
nextDelivery: '2024-11-03',
|
||||
status: 'active'
|
||||
}
|
||||
].map((supplier, index) => (
|
||||
<div key={index} className="border border-gray-200 rounded-lg p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h4 className="font-medium text-gray-900">{supplier.name}</h4>
|
||||
<span className="px-2 py-1 bg-green-100 text-green-800 text-xs rounded-full">
|
||||
{supplier.status === 'active' ? 'Activo' : 'Inactivo'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm text-gray-600">
|
||||
<span className="font-medium">Categoría:</span> {supplier.category}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">
|
||||
<span className="font-medium">Calificación:</span> ⭐ {supplier.rating}/5
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">
|
||||
<span className="font-medium">Confiabilidad:</span> {supplier.reliability}%
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">
|
||||
<span className="font-medium">Próxima entrega:</span> {new Date(supplier.nextDelivery).toLocaleDateString('es-ES')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex space-x-2">
|
||||
<button className="flex-1 px-3 py-2 text-sm bg-primary-100 text-primary-700 rounded-lg hover:bg-primary-200 transition-colors">
|
||||
Editar
|
||||
</button>
|
||||
<button className="px-3 py-2 text-sm bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition-colors">
|
||||
Contactar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-6 text-center">
|
||||
<button className="inline-flex items-center px-4 py-2 bg-primary-500 text-white rounded-lg hover:bg-primary-600 transition-colors">
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Añadir Proveedor
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* New Order Modal Placeholder */}
|
||||
{showNewOrder && (
|
||||
|
||||
671
frontend/src/pages/production/ProductionPage.tsx
Normal file
671
frontend/src/pages/production/ProductionPage.tsx
Normal file
@@ -0,0 +1,671 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Clock, Calendar, ChefHat, TrendingUp, AlertTriangle,
|
||||
CheckCircle, Settings, Plus, BarChart3, Users,
|
||||
Timer, Target, Activity, Zap
|
||||
} from 'lucide-react';
|
||||
|
||||
// Import existing complex components
|
||||
import ProductionSchedule from '../../components/ui/ProductionSchedule';
|
||||
import DemandHeatmap from '../../components/ui/DemandHeatmap';
|
||||
import { useDashboard } from '../../hooks/useDashboard';
|
||||
|
||||
// Types for production management
|
||||
interface ProductionMetrics {
|
||||
efficiency: number;
|
||||
onTimeCompletion: number;
|
||||
wastePercentage: number;
|
||||
energyUsage: number;
|
||||
staffUtilization: number;
|
||||
}
|
||||
|
||||
interface ProductionBatch {
|
||||
id: string;
|
||||
product: string;
|
||||
batchSize: number;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
status: 'planned' | 'in_progress' | 'completed' | 'delayed';
|
||||
assignedStaff: string[];
|
||||
actualYield: number;
|
||||
expectedYield: number;
|
||||
notes?: string;
|
||||
temperature?: number;
|
||||
humidity?: number;
|
||||
}
|
||||
|
||||
interface StaffMember {
|
||||
id: string;
|
||||
name: string;
|
||||
role: 'baker' | 'assistant' | 'decorator';
|
||||
currentTask?: string;
|
||||
status: 'available' | 'busy' | 'break';
|
||||
efficiency: number;
|
||||
}
|
||||
|
||||
interface Equipment {
|
||||
id: string;
|
||||
name: string;
|
||||
type: 'oven' | 'mixer' | 'proofer' | 'cooling_rack';
|
||||
status: 'idle' | 'in_use' | 'maintenance' | 'error';
|
||||
currentBatch?: string;
|
||||
temperature?: number;
|
||||
maintenanceDue?: string;
|
||||
}
|
||||
|
||||
const ProductionPage: React.FC = () => {
|
||||
const { todayForecasts, metrics, weather, isLoading } = useDashboard();
|
||||
const [activeTab, setActiveTab] = useState<'schedule' | 'batches' | 'analytics' | 'staff' | 'equipment'>('schedule');
|
||||
const [productionMetrics, setProductionMetrics] = useState<ProductionMetrics>({
|
||||
efficiency: 87.5,
|
||||
onTimeCompletion: 94.2,
|
||||
wastePercentage: 3.8,
|
||||
energyUsage: 156.7,
|
||||
staffUtilization: 78.3
|
||||
});
|
||||
|
||||
// Sample production schedule data
|
||||
const [productionSchedule, setProductionSchedule] = useState([
|
||||
{
|
||||
time: '05:00 AM',
|
||||
items: [
|
||||
{
|
||||
id: 'prod-1',
|
||||
product: 'Croissants',
|
||||
quantity: 48,
|
||||
priority: 'high' as const,
|
||||
estimatedTime: 180,
|
||||
status: 'in_progress' as const,
|
||||
confidence: 0.92,
|
||||
notes: 'Alta demanda prevista - lote doble'
|
||||
},
|
||||
{
|
||||
id: 'prod-2',
|
||||
product: 'Pan de molde',
|
||||
quantity: 35,
|
||||
priority: 'high' as const,
|
||||
estimatedTime: 240,
|
||||
status: 'pending' as const,
|
||||
confidence: 0.88
|
||||
}
|
||||
],
|
||||
totalTime: 420
|
||||
},
|
||||
{
|
||||
time: '08:00 AM',
|
||||
items: [
|
||||
{
|
||||
id: 'prod-3',
|
||||
product: 'Baguettes',
|
||||
quantity: 25,
|
||||
priority: 'medium' as const,
|
||||
estimatedTime: 200,
|
||||
status: 'pending' as const,
|
||||
confidence: 0.75
|
||||
},
|
||||
{
|
||||
id: 'prod-4',
|
||||
product: 'Magdalenas',
|
||||
quantity: 60,
|
||||
priority: 'medium' as const,
|
||||
estimatedTime: 120,
|
||||
status: 'pending' as const,
|
||||
confidence: 0.82
|
||||
}
|
||||
],
|
||||
totalTime: 320
|
||||
}
|
||||
]);
|
||||
|
||||
const [productionBatches, setProductionBatches] = useState<ProductionBatch[]>([
|
||||
{
|
||||
id: 'batch-1',
|
||||
product: 'Croissants',
|
||||
batchSize: 48,
|
||||
startTime: '05:00',
|
||||
endTime: '08:00',
|
||||
status: 'in_progress',
|
||||
assignedStaff: ['maria-lopez', 'carlos-ruiz'],
|
||||
actualYield: 45,
|
||||
expectedYield: 48,
|
||||
temperature: 180,
|
||||
humidity: 65,
|
||||
notes: 'Masa fermentando correctamente'
|
||||
},
|
||||
{
|
||||
id: 'batch-2',
|
||||
product: 'Pan de molde',
|
||||
batchSize: 35,
|
||||
startTime: '06:30',
|
||||
endTime: '10:30',
|
||||
status: 'planned',
|
||||
assignedStaff: ['ana-garcia'],
|
||||
actualYield: 0,
|
||||
expectedYield: 35,
|
||||
notes: 'Esperando finalización de croissants'
|
||||
}
|
||||
]);
|
||||
|
||||
const [staff, setStaff] = useState<StaffMember[]>([
|
||||
{
|
||||
id: 'maria-lopez',
|
||||
name: 'María López',
|
||||
role: 'baker',
|
||||
currentTask: 'Preparando croissants',
|
||||
status: 'busy',
|
||||
efficiency: 94.2
|
||||
},
|
||||
{
|
||||
id: 'carlos-ruiz',
|
||||
name: 'Carlos Ruiz',
|
||||
role: 'assistant',
|
||||
currentTask: 'Horneando croissants',
|
||||
status: 'busy',
|
||||
efficiency: 87.8
|
||||
},
|
||||
{
|
||||
id: 'ana-garcia',
|
||||
name: 'Ana García',
|
||||
role: 'baker',
|
||||
status: 'available',
|
||||
efficiency: 91.5
|
||||
}
|
||||
]);
|
||||
|
||||
const [equipment, setEquipment] = useState<Equipment[]>([
|
||||
{
|
||||
id: 'oven-1',
|
||||
name: 'Horno Principal',
|
||||
type: 'oven',
|
||||
status: 'in_use',
|
||||
currentBatch: 'batch-1',
|
||||
temperature: 180,
|
||||
maintenanceDue: '2024-11-15'
|
||||
},
|
||||
{
|
||||
id: 'mixer-1',
|
||||
name: 'Amasadora Industrial',
|
||||
type: 'mixer',
|
||||
status: 'idle',
|
||||
maintenanceDue: '2024-11-20'
|
||||
},
|
||||
{
|
||||
id: 'proofer-1',
|
||||
name: 'Fermentadora',
|
||||
type: 'proofer',
|
||||
status: 'in_use',
|
||||
currentBatch: 'batch-2',
|
||||
temperature: 28,
|
||||
maintenanceDue: '2024-12-01'
|
||||
}
|
||||
]);
|
||||
|
||||
// Demand heatmap sample data
|
||||
const heatmapData = [
|
||||
{
|
||||
weekStart: '2024-11-04',
|
||||
days: [
|
||||
{
|
||||
date: '2024-11-04',
|
||||
demand: 180,
|
||||
isToday: true,
|
||||
products: [
|
||||
{ name: 'Croissants', demand: 48, confidence: 'high' as const },
|
||||
{ name: 'Pan de molde', demand: 35, confidence: 'high' as const },
|
||||
{ name: 'Baguettes', demand: 25, confidence: 'medium' as const },
|
||||
{ name: 'Magdalenas', demand: 32, confidence: 'medium' as const },
|
||||
]
|
||||
},
|
||||
{
|
||||
date: '2024-11-05',
|
||||
demand: 165,
|
||||
isForecast: true,
|
||||
products: [
|
||||
{ name: 'Croissants', demand: 42, confidence: 'high' as const },
|
||||
{ name: 'Pan de molde', demand: 38, confidence: 'medium' as const },
|
||||
{ name: 'Baguettes', demand: 28, confidence: 'medium' as const },
|
||||
{ name: 'Magdalenas', demand: 28, confidence: 'low' as const },
|
||||
]
|
||||
},
|
||||
{
|
||||
date: '2024-11-06',
|
||||
demand: 195,
|
||||
isForecast: true,
|
||||
products: [
|
||||
{ name: 'Croissants', demand: 55, confidence: 'high' as const },
|
||||
{ name: 'Pan de molde', demand: 40, confidence: 'high' as const },
|
||||
{ name: 'Baguettes', demand: 32, confidence: 'medium' as const },
|
||||
{ name: 'Magdalenas', demand: 35, confidence: 'medium' as const },
|
||||
]
|
||||
},
|
||||
{ date: '2024-11-07', demand: 220, isForecast: true },
|
||||
{ date: '2024-11-08', demand: 185, isForecast: true },
|
||||
{ date: '2024-11-09', demand: 250, isForecast: true },
|
||||
{ date: '2024-11-10', demand: 160, isForecast: true }
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'planned':
|
||||
return 'bg-blue-100 text-blue-800';
|
||||
case 'in_progress':
|
||||
return 'bg-yellow-100 text-yellow-800';
|
||||
case 'completed':
|
||||
return 'bg-green-100 text-green-800';
|
||||
case 'delayed':
|
||||
return 'bg-red-100 text-red-800';
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-800';
|
||||
}
|
||||
};
|
||||
|
||||
const getEquipmentStatusColor = (status: Equipment['status']) => {
|
||||
switch (status) {
|
||||
case 'idle':
|
||||
return 'bg-gray-100 text-gray-800';
|
||||
case 'in_use':
|
||||
return 'bg-green-100 text-green-800';
|
||||
case 'maintenance':
|
||||
return 'bg-yellow-100 text-yellow-800';
|
||||
case 'error':
|
||||
return 'bg-red-100 text-red-800';
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-800';
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="animate-pulse space-y-6">
|
||||
<div className="h-8 bg-gray-200 rounded w-1/3"></div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
{[...Array(4)].map((_, i) => (
|
||||
<div key={i} className="h-32 bg-gray-200 rounded-xl"></div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6 bg-gray-50 min-h-screen">
|
||||
{/* Header */}
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||||
<div className="flex flex-col lg:flex-row lg:items-center lg:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 flex items-center">
|
||||
<ChefHat className="h-8 w-8 mr-3 text-primary-600" />
|
||||
Centro de Producción
|
||||
</h1>
|
||||
<p className="text-gray-600 mt-1">
|
||||
Gestión completa de la producción diaria y planificación inteligente
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 lg:mt-0 flex items-center space-x-4">
|
||||
<div className="bg-gray-50 rounded-lg px-4 py-2">
|
||||
<div className="text-sm font-medium text-gray-900">Eficiencia Hoy</div>
|
||||
<div className="text-2xl font-bold text-primary-600">{productionMetrics.efficiency}%</div>
|
||||
</div>
|
||||
|
||||
<button className="inline-flex items-center px-4 py-2 bg-primary-500 text-white rounded-xl hover:bg-primary-600 transition-colors">
|
||||
<Plus className="h-5 w-5 mr-2" />
|
||||
Nuevo Lote
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Key Metrics Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-4">
|
||||
<div className="bg-white p-6 rounded-xl shadow-sm border border-gray-200">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Eficiencia</p>
|
||||
<p className="text-2xl font-bold text-green-600">{productionMetrics.efficiency}%</p>
|
||||
</div>
|
||||
<div className="p-3 bg-green-100 rounded-lg">
|
||||
<Target className="h-6 w-6 text-green-600" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 flex items-center text-xs text-green-600">
|
||||
<TrendingUp className="h-3 w-3 mr-1" />
|
||||
+2.3% vs ayer
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white p-6 rounded-xl shadow-sm border border-gray-200">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">A Tiempo</p>
|
||||
<p className="text-2xl font-bold text-blue-600">{productionMetrics.onTimeCompletion}%</p>
|
||||
</div>
|
||||
<div className="p-3 bg-blue-100 rounded-lg">
|
||||
<Clock className="h-6 w-6 text-blue-600" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 flex items-center text-xs text-blue-600">
|
||||
<CheckCircle className="h-3 w-3 mr-1" />
|
||||
Muy bueno
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white p-6 rounded-xl shadow-sm border border-gray-200">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Desperdicio</p>
|
||||
<p className="text-2xl font-bold text-orange-600">{productionMetrics.wastePercentage}%</p>
|
||||
</div>
|
||||
<div className="p-3 bg-orange-100 rounded-lg">
|
||||
<AlertTriangle className="h-6 w-6 text-orange-600" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 flex items-center text-xs text-orange-600">
|
||||
<TrendingUp className="h-3 w-3 mr-1" />
|
||||
-0.5% vs ayer
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white p-6 rounded-xl shadow-sm border border-gray-200">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Energía</p>
|
||||
<p className="text-2xl font-bold text-purple-600">{productionMetrics.energyUsage} kW</p>
|
||||
</div>
|
||||
<div className="p-3 bg-purple-100 rounded-lg">
|
||||
<Zap className="h-6 w-6 text-purple-600" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 flex items-center text-xs text-purple-600">
|
||||
<Activity className="h-3 w-3 mr-1" />
|
||||
Normal
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white p-6 rounded-xl shadow-sm border border-gray-200">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Personal</p>
|
||||
<p className="text-2xl font-bold text-indigo-600">{productionMetrics.staffUtilization}%</p>
|
||||
</div>
|
||||
<div className="p-3 bg-indigo-100 rounded-lg">
|
||||
<Users className="h-6 w-6 text-indigo-600" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 flex items-center text-xs text-indigo-600">
|
||||
<Users className="h-3 w-3 mr-1" />
|
||||
3/4 activos
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs Navigation */}
|
||||
<div className="bg-white rounded-xl shadow-sm p-1">
|
||||
<div className="flex space-x-1">
|
||||
{[
|
||||
{ id: 'schedule', label: 'Programa', icon: Calendar },
|
||||
{ id: 'batches', label: 'Lotes Activos', icon: Timer },
|
||||
{ id: 'analytics', label: 'Análisis', icon: BarChart3 },
|
||||
{ id: 'staff', label: 'Personal', icon: Users },
|
||||
{ id: 'equipment', label: 'Equipos', icon: Settings }
|
||||
].map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id as any)}
|
||||
className={`flex-1 py-3 px-4 text-sm font-medium rounded-lg transition-all flex items-center justify-center ${
|
||||
activeTab === tab.id
|
||||
? 'bg-primary-100 text-primary-700'
|
||||
: 'text-gray-600 hover:text-gray-900 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
<tab.icon className="h-4 w-4 mr-2" />
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tab Content */}
|
||||
<div className="space-y-6">
|
||||
{activeTab === 'schedule' && (
|
||||
<>
|
||||
<ProductionSchedule
|
||||
schedule={productionSchedule}
|
||||
onUpdateQuantity={(itemId, quantity) => {
|
||||
setProductionSchedule(prev =>
|
||||
prev.map(slot => ({
|
||||
...slot,
|
||||
items: slot.items.map(item =>
|
||||
item.id === itemId ? { ...item, quantity } : item
|
||||
)
|
||||
}))
|
||||
);
|
||||
}}
|
||||
onUpdateStatus={(itemId, status) => {
|
||||
setProductionSchedule(prev =>
|
||||
prev.map(slot => ({
|
||||
...slot,
|
||||
items: slot.items.map(item =>
|
||||
item.id === itemId ? { ...item, status } : item
|
||||
)
|
||||
}))
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{activeTab === 'batches' && (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{productionBatches.map((batch) => (
|
||||
<div key={batch.id} className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="font-semibold text-gray-900">{batch.product}</h3>
|
||||
<span className={`px-3 py-1 rounded-full text-sm font-medium ${getStatusColor(batch.status)}`}>
|
||||
{batch.status === 'planned' ? 'Planificado' :
|
||||
batch.status === 'in_progress' ? 'En Progreso' :
|
||||
batch.status === 'completed' ? 'Completado' : 'Retrasado'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Tamaño del Lote</p>
|
||||
<p className="font-semibold text-gray-900">{batch.batchSize} unidades</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Rendimiento</p>
|
||||
<p className="font-semibold text-gray-900">
|
||||
{batch.actualYield || 0}/{batch.expectedYield}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Inicio</p>
|
||||
<p className="font-semibold text-gray-900">{batch.startTime}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Fin Estimado</p>
|
||||
<p className="font-semibold text-gray-900">{batch.endTime}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(batch.temperature || batch.humidity) && (
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{batch.temperature && (
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Temperatura</p>
|
||||
<p className="font-semibold text-gray-900">{batch.temperature}°C</p>
|
||||
</div>
|
||||
)}
|
||||
{batch.humidity && (
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Humedad</p>
|
||||
<p className="font-semibold text-gray-900">{batch.humidity}%</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<p className="text-sm text-gray-600 mb-2">Personal Asignado</p>
|
||||
<div className="flex space-x-2">
|
||||
{batch.assignedStaff.map((staffId) => {
|
||||
const staffMember = staff.find(s => s.id === staffId);
|
||||
return (
|
||||
<span
|
||||
key={staffId}
|
||||
className="px-2 py-1 bg-gray-100 text-gray-700 text-sm rounded"
|
||||
>
|
||||
{staffMember?.name || staffId}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{batch.notes && (
|
||||
<div className="bg-gray-50 rounded-lg p-3">
|
||||
<p className="text-sm text-gray-700">{batch.notes}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'analytics' && (
|
||||
<div className="space-y-6">
|
||||
<DemandHeatmap
|
||||
data={heatmapData}
|
||||
onDateClick={(date) => {
|
||||
console.log('Selected date:', date);
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Production Trends Chart Placeholder */}
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4 flex items-center">
|
||||
<BarChart3 className="h-5 w-5 mr-2 text-primary-600" />
|
||||
Tendencias de Producción
|
||||
</h3>
|
||||
<div className="h-64 bg-gray-50 rounded-lg flex items-center justify-center">
|
||||
<div className="text-center text-gray-500">
|
||||
<BarChart3 className="h-12 w-12 mx-auto mb-2 text-gray-400" />
|
||||
<p>Gráfico de tendencias de producción</p>
|
||||
<p className="text-sm">Próximamente disponible</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'staff' && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{staff.map((member) => (
|
||||
<div key={member.id} className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="font-semibold text-gray-900">{member.name}</h3>
|
||||
<span className={`px-2 py-1 rounded-full text-xs font-medium ${
|
||||
member.status === 'available' ? 'bg-green-100 text-green-800' :
|
||||
member.status === 'busy' ? 'bg-yellow-100 text-yellow-800' :
|
||||
'bg-gray-100 text-gray-800'
|
||||
}`}>
|
||||
{member.status === 'available' ? 'Disponible' :
|
||||
member.status === 'busy' ? 'Ocupado' : 'Descanso'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Rol</p>
|
||||
<p className="font-medium text-gray-900 capitalize">{member.role}</p>
|
||||
</div>
|
||||
|
||||
{member.currentTask && (
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Tarea Actual</p>
|
||||
<p className="font-medium text-gray-900">{member.currentTask}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Eficiencia</p>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="flex-1 bg-gray-200 rounded-full h-2">
|
||||
<div
|
||||
className="bg-primary-600 h-2 rounded-full"
|
||||
style={{ width: `${member.efficiency}%` }}
|
||||
></div>
|
||||
</div>
|
||||
<span className="text-sm font-medium text-gray-900">{member.efficiency}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'equipment' && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{equipment.map((item) => (
|
||||
<div key={item.id} className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="font-semibold text-gray-900">{item.name}</h3>
|
||||
<span className={`px-2 py-1 rounded-full text-xs font-medium ${getEquipmentStatusColor(item.status)}`}>
|
||||
{item.status === 'idle' ? 'Inactivo' :
|
||||
item.status === 'in_use' ? 'En Uso' :
|
||||
item.status === 'maintenance' ? 'Mantenimiento' : 'Error'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Tipo</p>
|
||||
<p className="font-medium text-gray-900 capitalize">{item.type}</p>
|
||||
</div>
|
||||
|
||||
{item.currentBatch && (
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Lote Actual</p>
|
||||
<p className="font-medium text-gray-900">
|
||||
{productionBatches.find(b => b.id === item.currentBatch)?.product || item.currentBatch}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{item.temperature && (
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Temperatura</p>
|
||||
<p className="font-medium text-gray-900">{item.temperature}°C</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{item.maintenanceDue && (
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Próximo Mantenimiento</p>
|
||||
<p className="font-medium text-orange-600">
|
||||
{new Date(item.maintenanceDue).toLocaleDateString('es-ES')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProductionPage;
|
||||
31
frontend/tsconfig.json
Normal file
31
frontend/tsconfig.json
Normal file
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"strict": false,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noFallthroughCasesInSwitch": false,
|
||||
|
||||
/* Path mapping */
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
11
frontend/tsconfig.node.json
Normal file
11
frontend/tsconfig.node.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true
|
||||
},
|
||||
"include": ["vite.config.js"]
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
# ================================================================
|
||||
# Kubernetes Deployment: infrastructure/kubernetes/base/forecasting-service.yaml
|
||||
# ================================================================
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: forecasting-service
|
||||
labels:
|
||||
app: forecasting-service
|
||||
spec:
|
||||
replicas: 2
|
||||
selector:
|
||||
matchLabels:
|
||||
app: forecasting-service
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: forecasting-service
|
||||
spec:
|
||||
containers:
|
||||
- name: forecasting-service
|
||||
image: bakery-forecasting/forecasting-service:latest
|
||||
ports:
|
||||
- containerPort: 8000
|
||||
env:
|
||||
- name: DATABASE_URL
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: forecasting-db-secret
|
||||
key: database-url
|
||||
- name: RABBITMQ_URL
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: rabbitmq-secret
|
||||
key: url
|
||||
- name: REDIS_URL
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: redis-secret
|
||||
key: url
|
||||
- name: TRAINING_SERVICE_URL
|
||||
value: "http://training-service:8000"
|
||||
- name: DATA_SERVICE_URL
|
||||
value: "http://data-service:8000"
|
||||
resources:
|
||||
requests:
|
||||
memory: "512Mi"
|
||||
cpu: "250m"
|
||||
limits:
|
||||
memory: "1Gi"
|
||||
cpu: "500m"
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /health
|
||||
port: 8000
|
||||
initialDelaySeconds: 30
|
||||
periodSeconds: 10
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /health
|
||||
port: 8000
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 5
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: forecasting-service
|
||||
labels:
|
||||
app: forecasting-service
|
||||
spec:
|
||||
selector:
|
||||
app: forecasting-service
|
||||
ports:
|
||||
- port: 8000
|
||||
targetPort: 8000
|
||||
type: ClusterIP
|
||||
|
||||
@@ -1,399 +0,0 @@
|
||||
# Complete Microservices Project Structure
|
||||
|
||||
## 📁 Project Root Structure
|
||||
|
||||
```
|
||||
bakery-forecasting-platform/
|
||||
├── README.md
|
||||
├── docker-compose.yml # Development environment
|
||||
├── docker-compose.prod.yml # Production environment
|
||||
├── .env.example # Environment variables template
|
||||
├── .gitignore # Git ignore file
|
||||
├── docs/ # Documentation
|
||||
│ ├── architecture/ # Architecture diagrams
|
||||
│ ├── api/ # API documentation
|
||||
│ └── deployment/ # Deployment guides
|
||||
├── scripts/ # Utility scripts
|
||||
│ ├── setup.sh # Development setup
|
||||
│ ├── test.sh # Run all tests
|
||||
│ └── deploy.sh # Deployment script
|
||||
├── gateway/ # API Gateway Service
|
||||
│ ├── Dockerfile
|
||||
│ ├── requirements.txt
|
||||
│ ├── app/
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── main.py
|
||||
│ │ ├── core/
|
||||
│ │ │ ├── __init__.py
|
||||
│ │ │ ├── config.py
|
||||
│ │ │ ├── auth.py
|
||||
│ │ │ └── service_discovery.py
|
||||
│ │ ├── middleware/
|
||||
│ │ │ ├── __init__.py
|
||||
│ │ │ ├── cors.py
|
||||
│ │ │ ├── auth.py
|
||||
│ │ │ └── logging.py
|
||||
│ │ └── routes/
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── auth.py
|
||||
│ │ ├── training.py
|
||||
│ │ ├── forecasting.py
|
||||
│ │ └── data.py
|
||||
│ └── tests/
|
||||
│ ├── __init__.py
|
||||
│ ├── test_gateway.py
|
||||
│ └── test_routes.py
|
||||
├── services/ # Individual Microservices
|
||||
│ ├── auth/ # Authentication Service
|
||||
│ │ ├── Dockerfile
|
||||
│ │ ├── requirements.txt
|
||||
│ │ ├── app/
|
||||
│ │ │ ├── __init__.py
|
||||
│ │ │ ├── main.py
|
||||
│ │ │ ├── core/
|
||||
│ │ │ │ ├── __init__.py
|
||||
│ │ │ │ ├── config.py
|
||||
│ │ │ │ ├── database.py
|
||||
│ │ │ │ ├── security.py
|
||||
│ │ │ │ └── auth.py
|
||||
│ │ │ ├── models/
|
||||
│ │ │ │ ├── __init__.py
|
||||
│ │ │ │ ├── users.py
|
||||
│ │ │ │ └── tokens.py
|
||||
│ │ │ ├── schemas/
|
||||
│ │ │ │ ├── __init__.py
|
||||
│ │ │ │ ├── users.py
|
||||
│ │ │ │ └── auth.py
|
||||
│ │ │ ├── services/
|
||||
│ │ │ │ ├── __init__.py
|
||||
│ │ │ │ ├── auth_service.py
|
||||
│ │ │ │ └── user_service.py
|
||||
│ │ │ └── api/
|
||||
│ │ │ ├── __init__.py
|
||||
│ │ │ ├── auth.py
|
||||
│ │ │ └── users.py
|
||||
│ │ ├── migrations/
|
||||
│ │ │ └── versions/
|
||||
│ │ └── tests/
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── conftest.py
|
||||
│ │ ├── test_auth.py
|
||||
│ │ └── test_users.py
|
||||
│ ├── training/ # ML Training Service
|
||||
│ │ ├── Dockerfile
|
||||
│ │ ├── requirements.txt
|
||||
│ │ ├── app/
|
||||
│ │ │ ├── __init__.py
|
||||
│ │ │ ├── main.py
|
||||
│ │ │ ├── core/
|
||||
│ │ │ │ ├── __init__.py
|
||||
│ │ │ │ ├── config.py
|
||||
│ │ │ │ ├── database.py
|
||||
│ │ │ │ └── auth.py
|
||||
│ │ │ ├── models/
|
||||
│ │ │ │ ├── __init__.py
|
||||
│ │ │ │ ├── training.py
|
||||
│ │ │ │ └── models.py
|
||||
│ │ │ ├── schemas/
|
||||
│ │ │ │ ├── __init__.py
|
||||
│ │ │ │ ├── training.py
|
||||
│ │ │ │ └── models.py
|
||||
│ │ │ ├── services/
|
||||
│ │ │ │ ├── __init__.py
|
||||
│ │ │ │ ├── training_service.py
|
||||
│ │ │ │ ├── model_service.py
|
||||
│ │ │ │ └── messaging.py
|
||||
│ │ │ ├── ml/
|
||||
│ │ │ │ ├── __init__.py
|
||||
│ │ │ │ ├── trainer.py
|
||||
│ │ │ │ ├── prophet_manager.py
|
||||
│ │ │ │ └── data_processor.py
|
||||
│ │ │ └── api/
|
||||
│ │ │ ├── __init__.py
|
||||
│ │ │ ├── training.py
|
||||
│ │ │ └── models.py
|
||||
│ │ ├── migrations/
|
||||
│ │ │ └── versions/
|
||||
│ │ └── tests/
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── conftest.py
|
||||
│ │ ├── test_training.py
|
||||
│ │ └── test_ml.py
|
||||
│ ├── forecasting/ # Forecasting Service
|
||||
│ │ ├── Dockerfile
|
||||
│ │ ├── requirements.txt
|
||||
│ │ ├── app/
|
||||
│ │ │ ├── __init__.py
|
||||
│ │ │ ├── main.py
|
||||
│ │ │ ├── core/
|
||||
│ │ │ │ ├── __init__.py
|
||||
│ │ │ │ ├── config.py
|
||||
│ │ │ │ ├── database.py
|
||||
│ │ │ │ └── auth.py
|
||||
│ │ │ ├── models/
|
||||
│ │ │ │ ├── __init__.py
|
||||
│ │ │ │ ├── forecasts.py
|
||||
│ │ │ │ └── predictions.py
|
||||
│ │ │ ├── schemas/
|
||||
│ │ │ │ ├── __init__.py
|
||||
│ │ │ │ └── forecasts.py
|
||||
│ │ │ ├── services/
|
||||
│ │ │ │ ├── __init__.py
|
||||
│ │ │ │ ├── forecasting_service.py
|
||||
│ │ │ │ ├── prediction_service.py
|
||||
│ │ │ │ └── messaging.py
|
||||
│ │ │ ├── ml/
|
||||
│ │ │ │ ├── __init__.py
|
||||
│ │ │ │ ├── predictor.py
|
||||
│ │ │ │ └── model_loader.py
|
||||
│ │ │ └── api/
|
||||
│ │ │ ├── __init__.py
|
||||
│ │ │ ├── forecasts.py
|
||||
│ │ │ └── predictions.py
|
||||
│ │ ├── migrations/
|
||||
│ │ │ └── versions/
|
||||
│ │ └── tests/
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── conftest.py
|
||||
│ │ ├── test_forecasting.py
|
||||
│ │ └── test_predictions.py
|
||||
│ ├── data/ # Data Service (External APIs)
|
||||
│ │ ├── Dockerfile
|
||||
│ │ ├── requirements.txt
|
||||
│ │ ├── app/
|
||||
│ │ │ ├── __init__.py
|
||||
│ │ │ ├── main.py
|
||||
│ │ │ ├── core/
|
||||
│ │ │ │ ├── __init__.py
|
||||
│ │ │ │ ├── config.py
|
||||
│ │ │ │ ├── database.py
|
||||
│ │ │ │ └── auth.py
|
||||
│ │ │ ├── models/
|
||||
│ │ │ │ ├── __init__.py
|
||||
│ │ │ │ ├── sales.py
|
||||
│ │ │ │ ├── weather.py
|
||||
│ │ │ │ └── traffic.py
|
||||
│ │ │ ├── schemas/
|
||||
│ │ │ │ ├── __init__.py
|
||||
│ │ │ │ ├── sales.py
|
||||
│ │ │ │ └── external.py
|
||||
│ │ │ ├── services/
|
||||
│ │ │ │ ├── __init__.py
|
||||
│ │ │ │ ├── sales_service.py
|
||||
│ │ │ │ ├── weather_service.py
|
||||
│ │ │ │ ├── traffic_service.py
|
||||
│ │ │ │ └── data_import_service.py
|
||||
│ │ │ ├── external/
|
||||
│ │ │ │ ├── __init__.py
|
||||
│ │ │ │ ├── aemet.py
|
||||
│ │ │ │ ├── madrid_opendata.py
|
||||
│ │ │ │ └── base_client.py
|
||||
│ │ │ └── api/
|
||||
│ │ │ ├── __init__.py
|
||||
│ │ │ ├── sales.py
|
||||
│ │ │ ├── weather.py
|
||||
│ │ │ └── traffic.py
|
||||
│ │ ├── migrations/
|
||||
│ │ │ └── versions/
|
||||
│ │ └── tests/
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── conftest.py
|
||||
│ │ ├── test_data.py
|
||||
│ │ └── test_external.py
|
||||
│ ├── notification/ # Notification Service
|
||||
│ │ ├── Dockerfile
|
||||
│ │ ├── requirements.txt
|
||||
│ │ ├── app/
|
||||
│ │ │ ├── __init__.py
|
||||
│ │ │ ├── main.py
|
||||
│ │ │ ├── core/
|
||||
│ │ │ │ ├── __init__.py
|
||||
│ │ │ │ ├── config.py
|
||||
│ │ │ │ ├── database.py
|
||||
│ │ │ │ └── auth.py
|
||||
│ │ │ ├── models/
|
||||
│ │ │ │ ├── __init__.py
|
||||
│ │ │ │ ├── notifications.py
|
||||
│ │ │ │ └── templates.py
|
||||
│ │ │ ├── schemas/
|
||||
│ │ │ │ ├── __init__.py
|
||||
│ │ │ │ └── notifications.py
|
||||
│ │ │ ├── services/
|
||||
│ │ │ │ ├── __init__.py
|
||||
│ │ │ │ ├── notification_service.py
|
||||
│ │ │ │ ├── email_service.py
|
||||
│ │ │ │ ├── whatsapp_service.py
|
||||
│ │ │ │ └── messaging.py
|
||||
│ │ │ └── api/
|
||||
│ │ │ ├── __init__.py
|
||||
│ │ │ └── notifications.py
|
||||
│ │ ├── migrations/
|
||||
│ │ │ └── versions/
|
||||
│ │ └── tests/
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── conftest.py
|
||||
│ │ └── test_notifications.py
|
||||
│ └── tenant/ # Tenant Management Service
|
||||
│ ├── Dockerfile
|
||||
│ ├── requirements.txt
|
||||
│ ├── app/
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── main.py
|
||||
│ │ ├── core/
|
||||
│ │ │ ├── __init__.py
|
||||
│ │ │ ├── config.py
|
||||
│ │ │ ├── database.py
|
||||
│ │ │ └── auth.py
|
||||
│ │ ├── models/
|
||||
│ │ │ ├── __init__.py
|
||||
│ │ │ ├── tenants.py
|
||||
│ │ │ └── subscriptions.py
|
||||
│ │ ├── schemas/
|
||||
│ │ │ ├── __init__.py
|
||||
│ │ │ └── tenants.py
|
||||
│ │ ├── services/
|
||||
│ │ │ ├── __init__.py
|
||||
│ │ │ ├── tenant_service.py
|
||||
│ │ │ ├── subscription_service.py
|
||||
│ │ │ └── messaging.py
|
||||
│ │ └── api/
|
||||
│ │ ├── __init__.py
|
||||
│ │ └── tenants.py
|
||||
│ ├── migrations/
|
||||
│ │ └── versions/
|
||||
│ └── tests/
|
||||
│ ├── __init__.py
|
||||
│ ├── conftest.py
|
||||
│ └── test_tenants.py
|
||||
├── shared/ # Shared Libraries
|
||||
│ ├── __init__.py
|
||||
│ ├── auth/
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── jwt_handler.py
|
||||
│ │ └── decorators.py
|
||||
│ ├── database/
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── base.py
|
||||
│ │ └── utils.py
|
||||
│ ├── messaging/
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── rabbitmq.py
|
||||
│ │ └── events.py
|
||||
│ ├── monitoring/
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── logging.py
|
||||
│ │ └── metrics.py
|
||||
│ └── utils/
|
||||
│ ├── __init__.py
|
||||
│ ├── datetime_utils.py
|
||||
│ └── validation.py
|
||||
├── frontend/ # Frontend Applications
|
||||
│ ├── dashboard/ # React Dashboard
|
||||
│ │ ├── package.json
|
||||
│ │ ├── package-lock.json
|
||||
│ │ ├── Dockerfile
|
||||
│ │ ├── public/
|
||||
│ │ ├── src/
|
||||
│ │ │ ├── components/
|
||||
│ │ │ ├── pages/
|
||||
│ │ │ ├── services/
|
||||
│ │ │ ├── hooks/
|
||||
│ │ │ └── utils/
|
||||
│ │ └── tests/
|
||||
│ └── marketing/ # Next.js Marketing Site
|
||||
│ ├── package.json
|
||||
│ ├── package-lock.json
|
||||
│ ├── Dockerfile
|
||||
│ ├── public/
|
||||
│ ├── src/
|
||||
│ │ ├── components/
|
||||
│ │ ├── pages/
|
||||
│ │ ├── styles/
|
||||
│ │ └── utils/
|
||||
│ └── tests/
|
||||
├── infrastructure/ # Infrastructure as Code
|
||||
│ ├── docker/
|
||||
│ │ ├── postgres/
|
||||
│ │ ├── redis/
|
||||
│ │ └── rabbitmq/
|
||||
│ ├── kubernetes/
|
||||
│ │ ├── base/
|
||||
│ │ ├── dev/
|
||||
│ │ ├── staging/
|
||||
│ │ └── production/
|
||||
│ ├── terraform/
|
||||
│ │ ├── modules/
|
||||
│ │ ├── environments/
|
||||
│ │ └── global/
|
||||
│ └── monitoring/
|
||||
│ ├── prometheus/
|
||||
│ ├── grafana/
|
||||
│ └── elk/
|
||||
├── deployment/ # Deployment Configurations
|
||||
│ ├── docker-compose.dev.yml
|
||||
│ ├── docker-compose.staging.yml
|
||||
│ ├── docker-compose.prod.yml
|
||||
│ ├── nginx/
|
||||
│ │ ├── nginx.conf
|
||||
│ │ └── ssl/
|
||||
│ └── ssl/
|
||||
└── tests/ # Integration Tests
|
||||
├── __init__.py
|
||||
├── integration/
|
||||
│ ├── __init__.py
|
||||
│ ├── test_auth_flow.py
|
||||
│ ├── test_training_flow.py
|
||||
│ └── test_forecasting_flow.py
|
||||
├── e2e/
|
||||
│ ├── __init__.py
|
||||
│ └── test_complete_flow.py
|
||||
└── performance/
|
||||
├── __init__.py
|
||||
└── test_load.py
|
||||
```
|
||||
|
||||
## 🔧 Key Changes from Monolithic Structure
|
||||
|
||||
### 1. **Service Separation**
|
||||
- Each service has its own database
|
||||
- Independent deployment and scaling
|
||||
- Service-specific dependencies
|
||||
|
||||
### 2. **API Gateway**
|
||||
- Single entry point for all clients
|
||||
- Request routing to appropriate services
|
||||
- Cross-cutting concerns (auth, logging, rate limiting)
|
||||
|
||||
### 3. **Shared Libraries**
|
||||
- Common functionality across services
|
||||
- Consistent authentication and database patterns
|
||||
- Reusable utilities
|
||||
|
||||
### 4. **Infrastructure as Code**
|
||||
- Kubernetes manifests for orchestration
|
||||
- Terraform for cloud infrastructure
|
||||
- Docker configurations for all services
|
||||
|
||||
### 5. **Independent Testing**
|
||||
- Each service has its own test suite
|
||||
- Integration tests for service communication
|
||||
- End-to-end tests for complete workflows
|
||||
|
||||
## 📋 Migration Steps
|
||||
|
||||
1. **Create New Project Structure**: Set up all directories and base files
|
||||
2. **Implement Shared Libraries**: Common functionality first
|
||||
3. **Build Services One by One**: Start with auth, then data, then training
|
||||
4. **Set Up API Gateway**: Route requests to services
|
||||
5. **Configure Infrastructure**: Docker, Kubernetes, monitoring
|
||||
6. **Migrate Frontend**: Update API calls to use gateway
|
||||
7. **Set Up CI/CD**: Automated testing and deployment
|
||||
|
||||
## 🚀 Benefits of This Structure
|
||||
|
||||
- **Independent Development**: Teams can work on different services
|
||||
- **Independent Deployment**: Deploy services without affecting others
|
||||
- **Technology Flexibility**: Each service can use different technologies
|
||||
- **Scalability**: Scale services independently based on load
|
||||
- **Fault Isolation**: Service failures don't cascade
|
||||
- **Easier Testing**: Unit tests per service, integration tests across services
|
||||
@@ -1,18 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
echo "🚀 Deploying Bakery Forecasting Platform..."
|
||||
|
||||
# Build and deploy all services
|
||||
docker-compose build
|
||||
docker-compose up -d
|
||||
|
||||
echo "Waiting for services to be healthy..."
|
||||
sleep 30
|
||||
|
||||
# Check service health
|
||||
echo "Checking service health..."
|
||||
curl -f http://localhost:8000/health || echo "Gateway health check failed"
|
||||
|
||||
echo "✅ Deployment completed"
|
||||
echo "Gateway: http://localhost:8000"
|
||||
echo "API Docs: http://localhost:8000/docs"
|
||||
@@ -1,14 +0,0 @@
|
||||
# scripts/docker-logs.sh
|
||||
#!/bin/bash
|
||||
|
||||
# View logs for specific service or all services
|
||||
|
||||
SERVICE=${1:-"all"}
|
||||
|
||||
if [ "$SERVICE" = "all" ]; then
|
||||
echo "📋 Showing logs for all services..."
|
||||
docker-compose logs -f --tail=100
|
||||
else
|
||||
echo "📋 Showing logs for $SERVICE..."
|
||||
docker-compose logs -f --tail=100 $SERVICE
|
||||
fi
|
||||
@@ -1,201 +0,0 @@
|
||||
#!/bin/bash
|
||||
# scripts/fix-frontend.sh - Frontend troubleshooting script
|
||||
|
||||
set -e
|
||||
|
||||
GREEN='\033[0;32m'
|
||||
BLUE='\033[0;34m'
|
||||
RED='\033[0;31m'
|
||||
YELLOW='\033[0;33m'
|
||||
NC='\033[0m'
|
||||
|
||||
print_step() {
|
||||
echo -e "${BLUE}[FRONTEND-FIX]${NC} $1"
|
||||
}
|
||||
|
||||
print_success() {
|
||||
echo -e "${GREEN}[SUCCESS]${NC} $1"
|
||||
}
|
||||
|
||||
print_error() {
|
||||
echo -e "${RED}[ERROR]${NC} $1"
|
||||
}
|
||||
|
||||
print_warning() {
|
||||
echo -e "${YELLOW}[WARNING]${NC} $1"
|
||||
}
|
||||
|
||||
# Check if we're in the right directory
|
||||
if [ ! -f "docker-compose.yml" ]; then
|
||||
print_error "docker-compose.yml not found. Please run this script from the project root."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
print_step "Starting frontend troubleshooting..."
|
||||
|
||||
# Step 1: Check frontend directory structure
|
||||
print_step "Checking frontend directory structure..."
|
||||
if [ ! -d "frontend" ]; then
|
||||
print_error "Frontend directory not found!"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cd frontend
|
||||
|
||||
# Step 2: Check package.json exists
|
||||
if [ ! -f "package.json" ]; then
|
||||
print_error "package.json not found in frontend directory!"
|
||||
exit 1
|
||||
fi
|
||||
print_success "package.json found"
|
||||
|
||||
# Step 3: Generate package-lock.json if missing
|
||||
if [ ! -f "package-lock.json" ]; then
|
||||
print_warning "package-lock.json not found. Generating..."
|
||||
npm install
|
||||
print_success "package-lock.json generated"
|
||||
else
|
||||
print_success "package-lock.json found"
|
||||
fi
|
||||
|
||||
# Step 4: Check for missing directories
|
||||
print_step "Creating missing directories..."
|
||||
mkdir -p src/pages
|
||||
mkdir -p src/components
|
||||
mkdir -p src/hooks
|
||||
mkdir -p src/services
|
||||
mkdir -p src/utils
|
||||
mkdir -p public
|
||||
print_success "Directory structure verified"
|
||||
|
||||
# Step 5: Check for App.tsx
|
||||
if [ ! -f "src/App.tsx" ]; then
|
||||
print_warning "src/App.tsx not found. Creating basic App.tsx..."
|
||||
|
||||
cat > src/App.tsx << 'EOF'
|
||||
import React from 'react'
|
||||
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom'
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<Router>
|
||||
<div className="App min-h-screen bg-gray-50">
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-6">
|
||||
🥖 PanIA Dashboard
|
||||
</h1>
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h2 className="text-xl font-semibold mb-4">
|
||||
Bienvenido a PanIA
|
||||
</h2>
|
||||
<p className="text-gray-600">
|
||||
Sistema de predicción de demanda para panaderías en Madrid
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Router>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
EOF
|
||||
print_success "Basic App.tsx created"
|
||||
else
|
||||
print_success "App.tsx found"
|
||||
fi
|
||||
|
||||
# Step 6: Check for index.css
|
||||
if [ ! -f "src/index.css" ]; then
|
||||
print_warning "src/index.css not found. Creating basic CSS..."
|
||||
|
||||
cat > src/index.css << 'EOF'
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||
sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
|
||||
monospace;
|
||||
}
|
||||
EOF
|
||||
print_success "Basic index.css created"
|
||||
else
|
||||
print_success "index.css found"
|
||||
fi
|
||||
|
||||
# Step 7: Go back to project root
|
||||
cd ..
|
||||
|
||||
# Step 8: Stop and remove the frontend container
|
||||
print_step "Stopping and removing existing frontend container..."
|
||||
docker-compose stop dashboard 2>/dev/null || true
|
||||
docker-compose rm -f dashboard 2>/dev/null || true
|
||||
|
||||
# Step 9: Remove the image to force rebuild
|
||||
print_step "Removing frontend image to force rebuild..."
|
||||
docker rmi bakery/dashboard:latest 2>/dev/null || true
|
||||
|
||||
# Step 10: Check .env file
|
||||
if [ ! -f ".env" ]; then
|
||||
print_warning ".env file not found. Creating from example..."
|
||||
if [ -f ".env.example" ]; then
|
||||
cp .env.example .env
|
||||
print_success ".env created from .env.example"
|
||||
else
|
||||
# Create minimal .env
|
||||
cat > .env << 'EOF'
|
||||
ENVIRONMENT=development
|
||||
DASHBOARD_PORT=3000
|
||||
GATEWAY_PORT=8000
|
||||
IMAGE_TAG=latest
|
||||
EOF
|
||||
print_success "Minimal .env created"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Step 11: Rebuild the frontend service
|
||||
print_step "Rebuilding frontend service..."
|
||||
docker-compose build --no-cache dashboard
|
||||
|
||||
# Step 12: Start the frontend service
|
||||
print_step "Starting frontend service..."
|
||||
docker-compose up -d dashboard
|
||||
|
||||
# Step 13: Wait and check status
|
||||
print_step "Waiting for frontend to start..."
|
||||
sleep 10
|
||||
|
||||
# Check if container is running
|
||||
if docker-compose ps dashboard | grep -q "Up"; then
|
||||
print_success "Frontend container is running!"
|
||||
print_step "Checking logs..."
|
||||
docker-compose logs --tail=20 dashboard
|
||||
|
||||
print_step "Testing frontend health..."
|
||||
sleep 5
|
||||
if curl -f http://localhost:3000 >/dev/null 2>&1; then
|
||||
print_success "Frontend is accessible at http://localhost:3000"
|
||||
else
|
||||
print_warning "Frontend is running but not yet accessible. Check logs above."
|
||||
fi
|
||||
else
|
||||
print_error "Frontend container failed to start. Check logs:"
|
||||
docker-compose logs dashboard
|
||||
fi
|
||||
|
||||
print_step "Frontend troubleshooting completed!"
|
||||
echo ""
|
||||
echo "Next steps:"
|
||||
echo "1. Check the frontend at: http://localhost:3000"
|
||||
echo "2. View logs with: docker-compose logs dashboard"
|
||||
echo "3. Restart if needed: docker-compose restart dashboard"
|
||||
@@ -1,90 +0,0 @@
|
||||
# scripts/docker-health-check.sh
|
||||
#!/bin/bash
|
||||
|
||||
# Comprehensive health check for all services
|
||||
|
||||
services=(
|
||||
"bakery-redis:6379"
|
||||
"bakery-rabbitmq:15672"
|
||||
"bakery-gateway:8000"
|
||||
"bakery-auth-service:8000"
|
||||
"bakery-tenant-service:8000"
|
||||
"bakery-training-service:8000"
|
||||
"bakery-forecasting-service:8000"
|
||||
"bakery-data-service:8000"
|
||||
"bakery-notification-service:8000"
|
||||
)
|
||||
|
||||
echo "🏥 Checking service health..."
|
||||
|
||||
for service_port in "${services[@]}"; do
|
||||
service=$(echo $service_port | cut -d: -f1)
|
||||
port=$(echo $service_port | cut -d: -f2)
|
||||
|
||||
if docker ps --format "table {{.Names}}" | grep -q "^$service$"; then
|
||||
if [ "$service" = "bakery-redis" ]; then
|
||||
# Redis health check
|
||||
if docker exec $service redis-cli -a redis_pass123 ping > /dev/null 2>&1; then
|
||||
echo "✅ $service is healthy"
|
||||
else
|
||||
echo "❌ $service is unhealthy"
|
||||
fi
|
||||
elif [ "$service" = "bakery-rabbitmq" ]; then
|
||||
# RabbitMQ health check
|
||||
if curl -s -u bakery:forecast123 http://localhost:$port/api/health/checks/alarms > /dev/null; then
|
||||
echo "✅ $service is healthy"
|
||||
else
|
||||
echo "❌ $service is unhealthy"
|
||||
fi
|
||||
else
|
||||
# HTTP service health check
|
||||
container_ip=$(docker inspect --format='{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' $service)
|
||||
if curl -f -s "http://$container_ip:8000/health" > /dev/null; then
|
||||
echo "✅ $service is healthy"
|
||||
else
|
||||
echo "❌ $service is unhealthy"
|
||||
fi
|
||||
fi
|
||||
else
|
||||
echo "⚠️ $service is not running"
|
||||
fi
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "🔍 Checking database connections..."
|
||||
|
||||
databases=("auth-db" "training-db" "forecasting-db" "data-db" "tenant-db" "notification-db")
|
||||
|
||||
for db in "${databases[@]}"; do
|
||||
if docker ps --format "table {{.Names}}" | grep -q "^bakery-$db$"; then
|
||||
db_name=$(echo $db | sed 's/-/_/g')
|
||||
user=$(echo $db | sed 's/-db//' | sed 's/-/_/g')_user
|
||||
|
||||
if docker exec bakery-$db pg_isready -U $user -d $db_name > /dev/null 2>&1; then
|
||||
echo "✅ bakery-$db is ready"
|
||||
else
|
||||
echo "❌ bakery-$db is not ready"
|
||||
fi
|
||||
else
|
||||
echo "⚠️ bakery-$db is not running"
|
||||
fi
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "📊 Service resource usage:"
|
||||
docker stats --no-stream --format "table {{.Container}}\t{{.CPUPerc}}\t{{.MemUsage}}" $(docker ps --format "{{.Names}}" | grep "^bakery-")
|
||||
|
||||
# scripts/docker-logs.sh
|
||||
#!/bin/bash
|
||||
|
||||
# View logs for specific service or all services
|
||||
|
||||
SERVICE=${1:-"all"}
|
||||
|
||||
if [ "$SERVICE" = "all" ]; then
|
||||
echo "📋 Showing logs for all services..."
|
||||
docker-compose logs -f --tail=100
|
||||
else
|
||||
echo "📋 Showing logs for $SERVICE..."
|
||||
docker-compose logs -f --tail=100 $SERVICE
|
||||
fi
|
||||
@@ -1,106 +0,0 @@
|
||||
# ================================================================
|
||||
# SERVICE RESTART SCRIPT
|
||||
# scripts/restart-service.sh
|
||||
# ================================================================
|
||||
|
||||
#!/bin/bash
|
||||
|
||||
# Restart individual service script
|
||||
|
||||
set -e
|
||||
|
||||
GREEN='\033[0;32m'
|
||||
BLUE='\033[0;34m'
|
||||
RED='\033[0;31m'
|
||||
NC='\033[0m'
|
||||
|
||||
print_step() {
|
||||
echo -e "${BLUE}[RESTART]${NC} $1"
|
||||
}
|
||||
|
||||
print_success() {
|
||||
echo -e "${GREEN}[SUCCESS]${NC} $1"
|
||||
}
|
||||
|
||||
print_error() {
|
||||
echo -e "${RED}[ERROR]${NC} $1"
|
||||
}
|
||||
|
||||
restart_service() {
|
||||
local service=$1
|
||||
|
||||
print_step "Restarting $service..."
|
||||
|
||||
# Build the service
|
||||
docker-compose build $service
|
||||
|
||||
# Restart with zero downtime
|
||||
docker-compose up -d --no-deps --force-recreate $service
|
||||
|
||||
# Wait a bit for the service to start
|
||||
sleep 5
|
||||
|
||||
# Check health
|
||||
local port
|
||||
case $service in
|
||||
"gateway") port=8000 ;;
|
||||
"auth-service") port=8001 ;;
|
||||
"training-service") port=8002 ;;
|
||||
"forecasting-service") port=8003 ;;
|
||||
"data-service") port=8004 ;;
|
||||
"tenant-service") port=8005 ;;
|
||||
"notification-service") port=8006 ;;
|
||||
*)
|
||||
print_error "Unknown service: $service"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
# Health check with timeout
|
||||
local attempts=0
|
||||
local max_attempts=12 # 60 seconds total
|
||||
|
||||
while [ $attempts -lt $max_attempts ]; do
|
||||
if curl -f -s "http://localhost:$port/health" > /dev/null 2>&1; then
|
||||
print_success "$service is healthy and ready"
|
||||
return 0
|
||||
fi
|
||||
|
||||
attempts=$((attempts + 1))
|
||||
echo "Waiting for $service to be healthy... ($attempts/$max_attempts)"
|
||||
sleep 5
|
||||
done
|
||||
|
||||
print_error "$service failed to become healthy within 60 seconds"
|
||||
return 1
|
||||
}
|
||||
|
||||
# Main function
|
||||
main() {
|
||||
if [ $# -eq 0 ]; then
|
||||
echo "Usage: $0 <service-name>"
|
||||
echo ""
|
||||
echo "Available services:"
|
||||
echo " gateway"
|
||||
echo " auth-service"
|
||||
echo " training-service"
|
||||
echo " forecasting-service"
|
||||
echo " data-service"
|
||||
echo " tenant-service"
|
||||
echo " notification-service"
|
||||
echo ""
|
||||
echo "Example: $0 auth-service"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
local service=$1
|
||||
|
||||
echo "================================================================"
|
||||
echo "RESTARTING SERVICE: $service"
|
||||
echo "================================================================"
|
||||
|
||||
restart_service $service
|
||||
}
|
||||
|
||||
# Run main function
|
||||
main "$@"
|
||||
@@ -1,392 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# ================================================================
|
||||
# Complete Authentication Test with Registration - FIXED VERSION
|
||||
# Tests the full user lifecycle: registration → login → API access
|
||||
# ================================================================
|
||||
|
||||
echo "🔐 Testing Complete Authentication System with Registration"
|
||||
echo "=========================================================="
|
||||
|
||||
# Configuration
|
||||
API_BASE="http://localhost:8000"
|
||||
AUTH_BASE="$API_BASE/api/v1/auth"
|
||||
TEST_EMAIL="test-$(date +%s)@bakery.com" # Unique email for each test
|
||||
TEST_PASSWORD="SecurePass123!"
|
||||
TEST_NAME="Test Baker"
|
||||
# ✅ FIX: Generate a proper UUID for tenant testing (will be replaced after bakery creation)
|
||||
TENANT_ID=$(uuidgen 2>/dev/null || python3 -c "import uuid; print(uuid.uuid4())" 2>/dev/null || echo "00000000-0000-0000-0000-000000000000")
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# Helper function for colored output
|
||||
log_step() {
|
||||
echo -e "${BLUE}📍 $1${NC}"
|
||||
}
|
||||
|
||||
log_success() {
|
||||
echo -e "${GREEN}✅ $1${NC}"
|
||||
}
|
||||
|
||||
log_error() {
|
||||
echo -e "${RED}❌ $1${NC}"
|
||||
}
|
||||
|
||||
log_warning() {
|
||||
echo -e "${YELLOW}⚠️ $1${NC}"
|
||||
}
|
||||
|
||||
# Helper function to check if service is healthy
|
||||
check_service_health() {
|
||||
local service_url=$1
|
||||
local service_name=$2
|
||||
|
||||
log_step "Checking $service_name health..."
|
||||
|
||||
response=$(curl -s -o /dev/null -w "%{http_code}" "$service_url/health")
|
||||
if [ "$response" = "200" ]; then
|
||||
log_success "$service_name is healthy"
|
||||
return 0
|
||||
else
|
||||
log_error "$service_name is not healthy (HTTP $response)"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Check all services are running
|
||||
log_step "Pre-flight checks..."
|
||||
echo ""
|
||||
|
||||
# Check API Gateway
|
||||
if ! check_service_health "$API_BASE" "API Gateway"; then
|
||||
log_error "API Gateway is not running. Start with: docker-compose up -d"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check Auth Service directly
|
||||
if ! check_service_health "http://localhost:8001" "Auth Service"; then
|
||||
log_error "Auth Service is not running. Check: docker-compose logs auth-service"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check Tenant Service
|
||||
if ! check_service_health "http://localhost:8005" "Tenant Service"; then
|
||||
log_error "Tenant Service is not running. Check: docker-compose logs tenant-service"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check Data Service
|
||||
if ! check_service_health "http://localhost:8004" "Data Service"; then
|
||||
log_warning "Data Service is not running, but continuing with auth tests..."
|
||||
fi
|
||||
|
||||
# Check Training Service
|
||||
if ! check_service_health "http://localhost:8002" "Training Service"; then
|
||||
log_warning "Training Service is not running, but continuing with auth tests..."
|
||||
fi
|
||||
|
||||
echo ""
|
||||
log_step "All systems ready! Starting authentication tests..."
|
||||
echo ""
|
||||
|
||||
# ================================================================
|
||||
# STEP 1: USER REGISTRATION
|
||||
# ================================================================
|
||||
|
||||
log_step "Step 1: Registering new user"
|
||||
echo "Email: $TEST_EMAIL"
|
||||
echo "Password: $TEST_PASSWORD"
|
||||
echo ""
|
||||
|
||||
REGISTRATION_RESPONSE=$(curl -s -X POST "$AUTH_BASE/register" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{
|
||||
\"email\": \"$TEST_EMAIL\",
|
||||
\"password\": \"$TEST_PASSWORD\",
|
||||
\"full_name\": \"$TEST_NAME\"
|
||||
}")
|
||||
|
||||
echo "Registration Response:"
|
||||
echo "$REGISTRATION_RESPONSE" | jq '.'
|
||||
|
||||
# Check if registration was successful
|
||||
if echo "$REGISTRATION_RESPONSE" | jq -e '.id' > /dev/null; then
|
||||
USER_ID=$(echo "$REGISTRATION_RESPONSE" | jq -r '.id')
|
||||
log_success "User registration successful! User ID: $USER_ID"
|
||||
else
|
||||
log_error "User registration failed!"
|
||||
echo "Response: $REGISTRATION_RESPONSE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo ""
|
||||
|
||||
# ================================================================
|
||||
# STEP 2: USER LOGIN
|
||||
# ================================================================
|
||||
|
||||
log_step "Step 2: Logging in with new user credentials"
|
||||
|
||||
LOGIN_RESPONSE=$(curl -s -X POST "$AUTH_BASE/login" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{
|
||||
\"email\": \"$TEST_EMAIL\",
|
||||
\"password\": \"$TEST_PASSWORD\"
|
||||
}")
|
||||
|
||||
echo "Login Response:"
|
||||
echo "$LOGIN_RESPONSE" | jq '.'
|
||||
|
||||
# Extract access token
|
||||
if echo "$LOGIN_RESPONSE" | jq -e '.access_token' > /dev/null; then
|
||||
ACCESS_TOKEN=$(echo "$LOGIN_RESPONSE" | jq -r '.access_token')
|
||||
REFRESH_TOKEN=$(echo "$LOGIN_RESPONSE" | jq -r '.refresh_token')
|
||||
log_success "Login successful! Token obtained: ${ACCESS_TOKEN:0:20}..."
|
||||
else
|
||||
log_error "Login failed!"
|
||||
echo "Response: $LOGIN_RESPONSE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo ""
|
||||
|
||||
# ================================================================
|
||||
# STEP 3: ACCESSING PROTECTED ENDPOINTS
|
||||
# ================================================================
|
||||
|
||||
log_step "Step 3: Testing protected endpoints with authentication"
|
||||
|
||||
# 3a. Get current user info
|
||||
log_step "3a. Getting current user profile"
|
||||
|
||||
USER_PROFILE_RESPONSE=$(curl -s -X GET "$API_BASE/api/v1/users/me" \
|
||||
-H "Authorization: Bearer $ACCESS_TOKEN")
|
||||
|
||||
echo "User Profile Response:"
|
||||
echo "$USER_PROFILE_RESPONSE" | jq '.'
|
||||
|
||||
if echo "$USER_PROFILE_RESPONSE" | jq -e '.email' > /dev/null; then
|
||||
log_success "User profile retrieved successfully!"
|
||||
else
|
||||
log_warning "User profile endpoint may not be implemented yet"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
|
||||
# ================================================================
|
||||
# STEP 4: TENANT REGISTRATION (BAKERY CREATION)
|
||||
# ================================================================
|
||||
|
||||
log_step "Step 4: Registering a bakery/tenant"
|
||||
|
||||
BAKERY_RESPONSE=$(curl -s -X POST "$API_BASE/api/v1/tenants/register" \
|
||||
-H "Authorization: Bearer $ACCESS_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{
|
||||
\"name\": \"Test Bakery $(date +%H%M)\",
|
||||
\"business_type\": \"bakery\",
|
||||
\"address\": \"Calle Test 123\",
|
||||
\"city\": \"Madrid\",
|
||||
\"postal_code\": \"28001\",
|
||||
\"phone\": \"+34600123456\"
|
||||
}")
|
||||
|
||||
echo "Bakery Registration Response:"
|
||||
echo "$BAKERY_RESPONSE" | jq '.'
|
||||
|
||||
if echo "$BAKERY_RESPONSE" | jq -e '.id' > /dev/null; then
|
||||
# ✅ FIX: Use the actual tenant ID returned from bakery creation
|
||||
TENANT_ID=$(echo "$BAKERY_RESPONSE" | jq -r '.id')
|
||||
log_success "Bakery registration successful! Tenant ID: $TENANT_ID"
|
||||
else
|
||||
log_error "Bakery registration failed!"
|
||||
echo "Response: $BAKERY_RESPONSE"
|
||||
# Continue with tests using placeholder UUID for other endpoints
|
||||
fi
|
||||
|
||||
echo ""
|
||||
|
||||
# ================================================================
|
||||
# STEP 5: TEST DATA SERVICE WITH TENANT ID
|
||||
# ================================================================
|
||||
|
||||
log_step "Step 5: Testing data service through gateway"
|
||||
|
||||
# Only test with valid tenant ID
|
||||
if [ "$TENANT_ID" != "00000000-0000-0000-0000-000000000000" ]; then
|
||||
DATA_RESPONSE=$(curl -s -X GET "$API_BASE/api/v1/data/sales" \
|
||||
-H "Authorization: Bearer $ACCESS_TOKEN" \
|
||||
-H "X-Tenant-ID: $TENANT_ID")
|
||||
|
||||
echo "Data Service Response:"
|
||||
echo "$DATA_RESPONSE" | jq '.'
|
||||
|
||||
if [ "$(echo "$DATA_RESPONSE" | jq -r '.status // "unknown"')" != "error" ]; then
|
||||
log_success "Data service access successful!"
|
||||
else
|
||||
log_warning "Data service returned error (may be expected for new tenant)"
|
||||
fi
|
||||
else
|
||||
log_warning "Skipping data service test - no valid tenant ID"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
|
||||
# ================================================================
|
||||
# STEP 6: TEST TRAINING SERVICE WITH TENANT ID
|
||||
# ================================================================
|
||||
|
||||
log_step "Step 6: Testing training service through gateway"
|
||||
|
||||
# Only test with valid tenant ID
|
||||
if [ "$TENANT_ID" != "00000000-0000-0000-0000-000000000000" ]; then
|
||||
TRAINING_RESPONSE=$(curl -s -X POST "$API_BASE/api/v1/training/jobs" \
|
||||
-H "Authorization: Bearer $ACCESS_TOKEN" \
|
||||
-H "X-Tenant-ID: $TENANT_ID" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"include_weather": true,
|
||||
"include_traffic": false,
|
||||
"min_data_points": 30
|
||||
}')
|
||||
|
||||
echo "Training Service Response:"
|
||||
echo "$TRAINING_RESPONSE" | jq '.'
|
||||
|
||||
if echo "$TRAINING_RESPONSE" | jq -e '.job_id // .message' > /dev/null; then
|
||||
log_success "Training service access successful!"
|
||||
else
|
||||
log_warning "Training service access may have issues"
|
||||
fi
|
||||
else
|
||||
log_warning "Skipping training service test - no valid tenant ID"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
|
||||
# ================================================================
|
||||
# STEP 7: TOKEN REFRESH
|
||||
# ================================================================
|
||||
|
||||
log_step "Step 7: Testing token refresh"
|
||||
|
||||
REFRESH_RESPONSE=$(curl -s -X POST "$AUTH_BASE/refresh" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{
|
||||
\"refresh_token\": \"$REFRESH_TOKEN\"
|
||||
}")
|
||||
|
||||
echo "Token Refresh Response:"
|
||||
echo "$REFRESH_RESPONSE" | jq '.'
|
||||
|
||||
if echo "$REFRESH_RESPONSE" | jq -e '.access_token' > /dev/null; then
|
||||
NEW_ACCESS_TOKEN=$(echo "$REFRESH_RESPONSE" | jq -r '.access_token')
|
||||
log_success "Token refresh successful! New token: ${NEW_ACCESS_TOKEN:0:20}..."
|
||||
else
|
||||
log_warning "Token refresh may not be fully implemented"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
|
||||
# ================================================================
|
||||
# STEP 8: DIRECT SERVICE HEALTH CHECKS
|
||||
# ================================================================
|
||||
|
||||
log_step "Step 8: Testing direct service access (without gateway)"
|
||||
|
||||
# Test auth service directly
|
||||
log_step "8a. Auth service direct health check"
|
||||
AUTH_HEALTH=$(curl -s -X GET "http://localhost:8001/health")
|
||||
echo "Auth Service Health:"
|
||||
echo "$AUTH_HEALTH" | jq '.'
|
||||
|
||||
# Test other services if available
|
||||
log_step "8b. Other services health check"
|
||||
|
||||
services=("8002:Training" "8003:Forecasting" "8004:Data" "8005:Tenant" "8006:Notification")
|
||||
|
||||
for service in "${services[@]}"; do
|
||||
port=$(echo $service | cut -d: -f1)
|
||||
name=$(echo $service | cut -d: -f2)
|
||||
|
||||
health_response=$(curl -s -o /dev/null -w "%{http_code}" "http://localhost:$port/health" 2>/dev/null)
|
||||
if [ "$health_response" = "200" ]; then
|
||||
log_success "$name Service (port $port) is healthy"
|
||||
else
|
||||
log_warning "$name Service (port $port) is not responding"
|
||||
fi
|
||||
done
|
||||
|
||||
echo ""
|
||||
|
||||
# ================================================================
|
||||
# STEP 9: LOGOUT
|
||||
# ================================================================
|
||||
|
||||
log_step "Step 9: Logging out user"
|
||||
|
||||
LOGOUT_RESPONSE=$(curl -s -X POST "$AUTH_BASE/logout" \
|
||||
-H "Authorization: Bearer $ACCESS_TOKEN" \
|
||||
-H "Content-Type: application/json")
|
||||
|
||||
echo "Logout Response:"
|
||||
echo "$LOGOUT_RESPONSE" | jq '.'
|
||||
|
||||
if echo "$LOGOUT_RESPONSE" | jq -e '.message' > /dev/null; then
|
||||
log_success "Logout successful!"
|
||||
else
|
||||
log_warning "Logout endpoint may not be fully implemented"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
|
||||
# ================================================================
|
||||
# SUMMARY
|
||||
# ================================================================
|
||||
|
||||
echo "🎉 Authentication Test Summary"
|
||||
echo "==============================="
|
||||
echo ""
|
||||
echo "Test User Created:"
|
||||
echo " 📧 Email: $TEST_EMAIL"
|
||||
echo " 👤 Name: $TEST_NAME"
|
||||
echo " 🆔 User ID: $USER_ID"
|
||||
echo ""
|
||||
echo "Authentication Flow:"
|
||||
echo " ✅ User Registration"
|
||||
echo " ✅ User Login"
|
||||
echo " ✅ Token Verification"
|
||||
echo " ✅ Protected Endpoint Access"
|
||||
echo " ✅ Token Refresh"
|
||||
echo " ✅ User Logout"
|
||||
echo ""
|
||||
echo "Services Tested:"
|
||||
echo " 🌐 API Gateway"
|
||||
echo " 🔐 Auth Service"
|
||||
echo " 🏢 Tenant Service (bakery registration)"
|
||||
echo " 📊 Data Service (through gateway)"
|
||||
echo " 🤖 Training Service (through gateway)"
|
||||
echo ""
|
||||
|
||||
if [ "$TENANT_ID" != "00000000-0000-0000-0000-000000000000" ]; then
|
||||
echo "Tenant Created:"
|
||||
echo " 🏪 Tenant ID: $TENANT_ID"
|
||||
echo ""
|
||||
fi
|
||||
|
||||
log_success "Complete authentication test finished successfully!"
|
||||
echo ""
|
||||
echo "🔧 Development Tips:"
|
||||
echo " • Use the created test user for further development"
|
||||
echo " • Check service logs with: docker-compose logs [service-name]"
|
||||
echo " • View API docs at: http://localhost:8000/docs"
|
||||
echo " • Monitor services at: http://localhost:3002"
|
||||
echo ""
|
||||
echo "🧹 Cleanup:"
|
||||
echo " • Test user will remain in database for development"
|
||||
echo " • To reset: Delete user from auth database or run cleanup script"
|
||||
@@ -49,7 +49,9 @@ async def get_current_user_info(
|
||||
)
|
||||
|
||||
# ✅ FIX: Fetch full user from database to get the real role
|
||||
user = await UserService.get_user_by_id(user_id, db)
|
||||
from app.repositories import UserRepository
|
||||
user_repo = UserRepository(User, db)
|
||||
user = await user_repo.get_by_id(user_id)
|
||||
|
||||
logger.debug(f"Fetched user from DB - Role: {user.role}, Email: {user.email}")
|
||||
|
||||
@@ -104,7 +106,21 @@ async def update_current_user(
|
||||
"""Update current user information"""
|
||||
try:
|
||||
user_id = current_user.get("user_id") if isinstance(current_user, dict) else current_user.id
|
||||
updated_user = await UserService.update_user(user_id, user_update, db)
|
||||
from app.repositories import UserRepository
|
||||
user_repo = UserRepository(User, db)
|
||||
|
||||
# Prepare update data
|
||||
update_data = {}
|
||||
if user_update.full_name is not None:
|
||||
update_data["full_name"] = user_update.full_name
|
||||
if user_update.phone is not None:
|
||||
update_data["phone"] = user_update.phone
|
||||
if user_update.language is not None:
|
||||
update_data["language"] = user_update.language
|
||||
if user_update.timezone is not None:
|
||||
update_data["timezone"] = user_update.timezone
|
||||
|
||||
updated_user = await user_repo.update(user_id, update_data)
|
||||
return UserResponse(
|
||||
id=str(updated_user.id),
|
||||
email=updated_user.email,
|
||||
|
||||
@@ -259,7 +259,7 @@ class EnhancedAuthService:
|
||||
"""Logout user using repository pattern"""
|
||||
try:
|
||||
async with self.database_manager.get_session() as session:
|
||||
token_repo = TokenRepository(session)
|
||||
token_repo = TokenRepository(RefreshToken, session)
|
||||
|
||||
# Revoke specific refresh token using repository
|
||||
success = await token_repo.revoke_token(user_id, refresh_token)
|
||||
@@ -291,8 +291,8 @@ class EnhancedAuthService:
|
||||
)
|
||||
|
||||
async with self.database_manager.get_session() as session:
|
||||
user_repo = UserRepository(session)
|
||||
token_repo = TokenRepository(session)
|
||||
user_repo = UserRepository(User, session)
|
||||
token_repo = TokenRepository(RefreshToken, session)
|
||||
|
||||
# Validate refresh token using repository
|
||||
is_valid = await token_repo.validate_refresh_token(refresh_token, user_id)
|
||||
@@ -364,7 +364,7 @@ class EnhancedAuthService:
|
||||
"""Get user profile using repository pattern"""
|
||||
try:
|
||||
async with self.database_manager.get_session() as session:
|
||||
user_repo = UserRepository(session)
|
||||
user_repo = UserRepository(User, session)
|
||||
|
||||
user = await user_repo.get_by_id(user_id)
|
||||
if not user:
|
||||
@@ -394,7 +394,7 @@ class EnhancedAuthService:
|
||||
"""Update user profile using repository pattern"""
|
||||
try:
|
||||
async with self.database_manager.get_session() as session:
|
||||
user_repo = UserRepository(session)
|
||||
user_repo = UserRepository(User, session)
|
||||
|
||||
updated_user = await user_repo.update(user_id, update_data)
|
||||
if not updated_user:
|
||||
@@ -429,8 +429,8 @@ class EnhancedAuthService:
|
||||
"""Change user password using repository pattern"""
|
||||
try:
|
||||
async with self.database_manager.get_session() as session:
|
||||
user_repo = UserRepository(session)
|
||||
token_repo = TokenRepository(session)
|
||||
user_repo = UserRepository(User, session)
|
||||
token_repo = TokenRepository(RefreshToken, session)
|
||||
|
||||
# Get user and verify old password
|
||||
user = await user_repo.get_by_id(user_id)
|
||||
@@ -470,7 +470,7 @@ class EnhancedAuthService:
|
||||
"""Verify user email using repository pattern"""
|
||||
try:
|
||||
async with self.database_manager.get_session() as session:
|
||||
user_repo = UserRepository(session)
|
||||
user_repo = UserRepository(User, session)
|
||||
|
||||
# In a real implementation, you'd verify the verification_token
|
||||
# For now, just mark user as verified
|
||||
@@ -493,8 +493,8 @@ class EnhancedAuthService:
|
||||
"""Deactivate user account using repository pattern"""
|
||||
try:
|
||||
async with self.database_manager.get_session() as session:
|
||||
user_repo = UserRepository(session)
|
||||
token_repo = TokenRepository(session)
|
||||
user_repo = UserRepository(User, session)
|
||||
token_repo = TokenRepository(RefreshToken, session)
|
||||
|
||||
# Update user status
|
||||
updated_user = await user_repo.update(user_id, {"is_active": False})
|
||||
|
||||
@@ -10,6 +10,7 @@ import structlog
|
||||
|
||||
from app.repositories import UserRepository, TokenRepository
|
||||
from app.schemas.auth import UserResponse, UserUpdate
|
||||
from app.models.users import User, RefreshToken
|
||||
from app.core.security import SecurityManager
|
||||
from shared.database.unit_of_work import UnitOfWork
|
||||
from shared.database.transactions import transactional
|
||||
@@ -29,7 +30,7 @@ class EnhancedUserService:
|
||||
"""Get user by ID using repository pattern"""
|
||||
try:
|
||||
async with self.database_manager.get_session() as session:
|
||||
user_repo = UserRepository(session)
|
||||
user_repo = UserRepository(User, session)
|
||||
|
||||
user = await user_repo.get_by_id(user_id)
|
||||
if not user:
|
||||
@@ -58,7 +59,7 @@ class EnhancedUserService:
|
||||
"""Get user by email using repository pattern"""
|
||||
try:
|
||||
async with self.database_manager.get_session() as session:
|
||||
user_repo = UserRepository(session)
|
||||
user_repo = UserRepository(User, session)
|
||||
|
||||
user = await user_repo.get_by_email(email)
|
||||
if not user:
|
||||
@@ -93,7 +94,7 @@ class EnhancedUserService:
|
||||
"""Get paginated list of users using repository pattern"""
|
||||
try:
|
||||
async with self.database_manager.get_session() as session:
|
||||
user_repo = UserRepository(session)
|
||||
user_repo = UserRepository(User, session)
|
||||
|
||||
filters = {}
|
||||
if active_only:
|
||||
@@ -139,7 +140,7 @@ class EnhancedUserService:
|
||||
"""Update user information using repository pattern"""
|
||||
try:
|
||||
async with self.database_manager.get_session() as db_session:
|
||||
user_repo = UserRepository(db_session)
|
||||
user_repo = UserRepository(User, db_session)
|
||||
|
||||
# Validate user exists
|
||||
existing_user = await user_repo.get_by_id(user_id)
|
||||
@@ -298,7 +299,7 @@ class EnhancedUserService:
|
||||
"""Activate user account using repository pattern"""
|
||||
try:
|
||||
async with self.database_manager.get_session() as db_session:
|
||||
user_repo = UserRepository(db_session)
|
||||
user_repo = UserRepository(User, db_session)
|
||||
|
||||
# Update user status
|
||||
updated_user = await user_repo.update(user_id, {"is_active": True})
|
||||
@@ -321,7 +322,7 @@ class EnhancedUserService:
|
||||
"""Verify user email using repository pattern"""
|
||||
try:
|
||||
async with self.database_manager.get_session() as session:
|
||||
user_repo = UserRepository(session)
|
||||
user_repo = UserRepository(User, session)
|
||||
|
||||
# In a real implementation, you'd verify the verification_token
|
||||
# For now, just mark user as verified
|
||||
@@ -344,7 +345,7 @@ class EnhancedUserService:
|
||||
"""Get user statistics using repository pattern"""
|
||||
try:
|
||||
async with self.database_manager.get_session() as session:
|
||||
user_repo = UserRepository(session)
|
||||
user_repo = UserRepository(User, session)
|
||||
|
||||
# Get basic user statistics
|
||||
statistics = await user_repo.get_user_statistics()
|
||||
@@ -372,7 +373,7 @@ class EnhancedUserService:
|
||||
"""Search users by email or name using repository pattern"""
|
||||
try:
|
||||
async with self.database_manager.get_session() as session:
|
||||
user_repo = UserRepository(session)
|
||||
user_repo = UserRepository(User, session)
|
||||
|
||||
users = await user_repo.search_users(
|
||||
search_term, role, active_only, skip, limit
|
||||
@@ -409,7 +410,7 @@ class EnhancedUserService:
|
||||
"""Update user role using repository pattern"""
|
||||
try:
|
||||
async with self.database_manager.get_session() as session:
|
||||
user_repo = UserRepository(session)
|
||||
user_repo = UserRepository(User, session)
|
||||
|
||||
# Validate role
|
||||
valid_roles = ["user", "admin", "super_admin"]
|
||||
@@ -452,8 +453,8 @@ class EnhancedUserService:
|
||||
"""Get user activity information using repository pattern"""
|
||||
try:
|
||||
async with self.database_manager.get_session() as session:
|
||||
user_repo = UserRepository(session)
|
||||
token_repo = TokenRepository(session)
|
||||
user_repo = UserRepository(User, session)
|
||||
token_repo = TokenRepository(RefreshToken, session)
|
||||
|
||||
# Get user
|
||||
user = await user_repo.get_by_id(user_id)
|
||||
|
||||
@@ -197,7 +197,7 @@ class ForecastingBaseRepository(BaseRepository):
|
||||
errors = []
|
||||
|
||||
for field in required_fields:
|
||||
if field not in data or not data[field]:
|
||||
if field not in data or data[field] is None:
|
||||
errors.append(f"Missing required field: {field}")
|
||||
|
||||
# Validate tenant_id format if present
|
||||
|
||||
@@ -1,219 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Comprehensive test to verify all services can start correctly after database refactoring
|
||||
"""
|
||||
|
||||
import subprocess
|
||||
import time
|
||||
import sys
|
||||
from typing import Dict, List, Tuple
|
||||
|
||||
# Service configurations
|
||||
SERVICES = {
|
||||
"infrastructure": {
|
||||
"services": ["redis", "rabbitmq", "auth-db", "data-db", "tenant-db",
|
||||
"forecasting-db", "notification-db", "training-db", "prometheus"],
|
||||
"wait_time": 60
|
||||
},
|
||||
"core": {
|
||||
"services": ["auth-service", "data-service"],
|
||||
"wait_time": 45,
|
||||
"health_checks": [
|
||||
("auth-service", "http://localhost:8001/health"),
|
||||
("data-service", "http://localhost:8002/health"),
|
||||
]
|
||||
},
|
||||
"business": {
|
||||
"services": ["tenant-service", "training-service", "forecasting-service",
|
||||
"notification-service"],
|
||||
"wait_time": 45,
|
||||
"health_checks": [
|
||||
("tenant-service", "http://localhost:8003/health"),
|
||||
("training-service", "http://localhost:8004/health"),
|
||||
("forecasting-service", "http://localhost:8005/health"),
|
||||
("notification-service", "http://localhost:8006/health"),
|
||||
]
|
||||
},
|
||||
"ui": {
|
||||
"services": ["gateway", "dashboard"],
|
||||
"wait_time": 30,
|
||||
"health_checks": [
|
||||
("gateway", "http://localhost:8000/health"),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
def run_command(command: str, description: str = None) -> Tuple[int, str]:
|
||||
"""Run a shell command and return exit code and output"""
|
||||
if description:
|
||||
print(f"Running: {command}")
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
command,
|
||||
shell=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=120
|
||||
)
|
||||
return result.returncode, result.stdout + result.stderr
|
||||
except subprocess.TimeoutExpired:
|
||||
return -1, f"Command timed out: {command}"
|
||||
|
||||
def check_container_status() -> Dict[str, str]:
|
||||
"""Get status of all containers"""
|
||||
exit_code, output = run_command("docker compose ps")
|
||||
|
||||
if exit_code != 0:
|
||||
return {}
|
||||
|
||||
status_dict = {}
|
||||
lines = output.strip().split('\n')[1:] # Skip header
|
||||
|
||||
for line in lines:
|
||||
if line.strip():
|
||||
parts = line.split()
|
||||
if len(parts) >= 4:
|
||||
name = parts[0].replace('bakery-', '')
|
||||
status = ' '.join(parts[3:])
|
||||
status_dict[name] = status
|
||||
|
||||
return status_dict
|
||||
|
||||
def check_health(service: str, url: str) -> bool:
|
||||
"""Check if a service health endpoint is responding"""
|
||||
try:
|
||||
# Use curl for health checks instead of requests
|
||||
exit_code, _ = run_command(f"curl -f {url}", None)
|
||||
return exit_code == 0
|
||||
except Exception as e:
|
||||
print(f"❌ Health check failed for {service}: {str(e)}")
|
||||
return False
|
||||
|
||||
def wait_for_healthy_containers(services: List[str], max_wait: int = 60) -> bool:
|
||||
"""Wait for containers to become healthy"""
|
||||
print(f"⏳ Waiting up to {max_wait} seconds for services to become healthy...")
|
||||
|
||||
for i in range(max_wait):
|
||||
status = check_container_status()
|
||||
healthy_count = 0
|
||||
|
||||
for service in services:
|
||||
service_status = status.get(service, "not found")
|
||||
if "healthy" in service_status.lower() or "up" in service_status.lower():
|
||||
healthy_count += 1
|
||||
|
||||
if healthy_count == len(services):
|
||||
print(f"✅ All {len(services)} services are healthy after {i+1} seconds")
|
||||
return True
|
||||
|
||||
time.sleep(1)
|
||||
|
||||
print(f"⚠️ Only {healthy_count}/{len(services)} services became healthy")
|
||||
return False
|
||||
|
||||
def test_service_group(group_name: str, config: Dict) -> bool:
|
||||
"""Test a group of services"""
|
||||
print(f"\n🧪 Testing {group_name} services...")
|
||||
print(f"Services: {', '.join(config['services'])}")
|
||||
|
||||
# Start services
|
||||
services_str = ' '.join(config['services'])
|
||||
exit_code, output = run_command(f"docker compose up -d {services_str}")
|
||||
|
||||
if exit_code != 0:
|
||||
print(f"❌ Failed to start {group_name} services")
|
||||
print(output[-1000:]) # Last 1000 chars of output
|
||||
return False
|
||||
|
||||
print(f"✅ {group_name.title()} services started")
|
||||
|
||||
# Wait for services to be healthy
|
||||
if not wait_for_healthy_containers(config['services'], config['wait_time']):
|
||||
print(f"⚠️ Some {group_name} services didn't become healthy")
|
||||
|
||||
# Show container status
|
||||
status = check_container_status()
|
||||
for service in config['services']:
|
||||
service_status = status.get(service, "not found")
|
||||
print(f" {service}: {service_status}")
|
||||
|
||||
# Show logs for failed services
|
||||
for service in config['services']:
|
||||
service_status = status.get(service, "")
|
||||
if "unhealthy" in service_status.lower() or "restarting" in service_status.lower():
|
||||
print(f"\n📋 Logs for failed service {service}:")
|
||||
_, logs = run_command(f"docker compose logs --tail=10 {service}")
|
||||
print(logs[-800:]) # Last 800 chars
|
||||
|
||||
return False
|
||||
|
||||
# Run health checks if defined
|
||||
if "health_checks" in config:
|
||||
print(f"🔍 Running health checks for {group_name} services...")
|
||||
|
||||
for service, url in config['health_checks']:
|
||||
if check_health(service, url):
|
||||
print(f"✅ {service} health check passed")
|
||||
else:
|
||||
print(f"❌ {service} health check failed")
|
||||
return False
|
||||
|
||||
print(f"🎉 {group_name.title()} services test PASSED!")
|
||||
return True
|
||||
|
||||
def main():
|
||||
"""Main test function"""
|
||||
print("🔧 COMPREHENSIVE SERVICES STARTUP TEST")
|
||||
print("=" * 50)
|
||||
|
||||
# Clean up any existing containers
|
||||
print("🧹 Cleaning up existing containers...")
|
||||
run_command("docker compose down")
|
||||
|
||||
all_passed = True
|
||||
|
||||
# Test each service group in order
|
||||
for group_name, config in SERVICES.items():
|
||||
if not test_service_group(group_name, config):
|
||||
all_passed = False
|
||||
break
|
||||
|
||||
# Final status check
|
||||
print("\n📊 Final container status:")
|
||||
status = check_container_status()
|
||||
|
||||
healthy_count = 0
|
||||
total_count = 0
|
||||
|
||||
for group_config in SERVICES.values():
|
||||
for service in group_config['services']:
|
||||
total_count += 1
|
||||
service_status = status.get(service, "not found")
|
||||
status_icon = "✅" if ("healthy" in service_status.lower() or "up" in service_status.lower()) else "❌"
|
||||
|
||||
if "healthy" in service_status.lower() or "up" in service_status.lower():
|
||||
healthy_count += 1
|
||||
|
||||
print(f"{status_icon} {service}: {service_status}")
|
||||
|
||||
# Clean up
|
||||
print("\n🧹 Cleaning up containers...")
|
||||
run_command("docker compose down")
|
||||
|
||||
# Final result
|
||||
print("=" * 50)
|
||||
if all_passed and healthy_count == total_count:
|
||||
print(f"🎉 ALL SERVICES TEST PASSED!")
|
||||
print(f"✅ {healthy_count}/{total_count} services started successfully")
|
||||
print("💡 Your docker-compose setup is working correctly")
|
||||
print("🚀 You can now run: docker compose up -d")
|
||||
return 0
|
||||
else:
|
||||
print(f"❌ SERVICES TEST FAILED")
|
||||
print(f"⚠️ {healthy_count}/{total_count} services started successfully")
|
||||
print("💡 Check the logs above for details")
|
||||
return 1
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -1,295 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Docker Build and Compose Test Script
|
||||
Tests that each service can be built correctly and docker-compose starts without errors
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import subprocess
|
||||
import time
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
def run_command(cmd, cwd=None, timeout=300, capture_output=True):
|
||||
"""Run a shell command with timeout and error handling"""
|
||||
try:
|
||||
print(f"Running: {cmd}")
|
||||
if capture_output:
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
shell=True,
|
||||
cwd=cwd,
|
||||
timeout=timeout,
|
||||
capture_output=True,
|
||||
text=True
|
||||
)
|
||||
else:
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
shell=True,
|
||||
cwd=cwd,
|
||||
timeout=timeout
|
||||
)
|
||||
return result
|
||||
except subprocess.TimeoutExpired:
|
||||
print(f"Command timed out after {timeout} seconds: {cmd}")
|
||||
return None
|
||||
except Exception as e:
|
||||
print(f"Error running command: {e}")
|
||||
return None
|
||||
|
||||
def check_docker_available():
|
||||
"""Check if Docker is available and running"""
|
||||
print("🐳 Checking Docker availability...")
|
||||
|
||||
# Check if docker command exists
|
||||
result = run_command("which docker")
|
||||
if result.returncode != 0:
|
||||
print("❌ Docker command not found. Please install Docker.")
|
||||
return False
|
||||
|
||||
# Check if Docker daemon is running
|
||||
result = run_command("docker version")
|
||||
if result.returncode != 0:
|
||||
print("❌ Docker daemon is not running. Please start Docker.")
|
||||
return False
|
||||
|
||||
# Check if docker-compose is available
|
||||
result = run_command("docker compose version")
|
||||
if result.returncode != 0:
|
||||
# Try legacy docker-compose
|
||||
result = run_command("docker-compose version")
|
||||
if result.returncode != 0:
|
||||
print("❌ docker-compose not found. Please install docker-compose.")
|
||||
return False
|
||||
else:
|
||||
print("✅ Using legacy docker-compose")
|
||||
else:
|
||||
print("✅ Using Docker Compose v2")
|
||||
|
||||
print("✅ Docker is available and running")
|
||||
return True
|
||||
|
||||
def test_individual_builds():
|
||||
"""Test building each service individually"""
|
||||
print("\n🔨 Testing individual service builds...")
|
||||
|
||||
services = [
|
||||
"auth-service",
|
||||
"tenant-service",
|
||||
"training-service",
|
||||
"forecasting-service",
|
||||
"data-service",
|
||||
"notification-service"
|
||||
]
|
||||
|
||||
build_results = {}
|
||||
|
||||
for service in services:
|
||||
print(f"\n--- Building {service} ---")
|
||||
|
||||
dockerfile_path = f"./services/{service.replace('-service', '')}/Dockerfile"
|
||||
if not os.path.exists(dockerfile_path):
|
||||
print(f"❌ Dockerfile not found: {dockerfile_path}")
|
||||
build_results[service] = False
|
||||
continue
|
||||
|
||||
# Build the service
|
||||
cmd = f"docker build -t bakery/{service}:test -f {dockerfile_path} ."
|
||||
result = run_command(cmd, timeout=600)
|
||||
|
||||
if result and result.returncode == 0:
|
||||
print(f"✅ {service} built successfully")
|
||||
build_results[service] = True
|
||||
else:
|
||||
print(f"❌ {service} build failed")
|
||||
if result and result.stderr:
|
||||
print(f"Error: {result.stderr}")
|
||||
build_results[service] = False
|
||||
|
||||
return build_results
|
||||
|
||||
def test_docker_compose_config():
|
||||
"""Test docker-compose configuration"""
|
||||
print("\n📋 Testing docker-compose configuration...")
|
||||
|
||||
# Check if docker-compose.yml exists
|
||||
if not os.path.exists("docker-compose.yml"):
|
||||
print("❌ docker-compose.yml not found")
|
||||
return False
|
||||
|
||||
# Check if .env file exists
|
||||
if not os.path.exists(".env"):
|
||||
print("❌ .env file not found")
|
||||
return False
|
||||
|
||||
# Validate docker-compose configuration
|
||||
result = run_command("docker compose config")
|
||||
if result.returncode != 0:
|
||||
# Try legacy docker-compose
|
||||
result = run_command("docker-compose config")
|
||||
if result.returncode != 0:
|
||||
print("❌ docker-compose configuration validation failed")
|
||||
if result.stderr:
|
||||
print(f"Error: {result.stderr}")
|
||||
return False
|
||||
|
||||
print("✅ docker-compose configuration is valid")
|
||||
return True
|
||||
|
||||
def test_essential_services_startup():
|
||||
"""Test starting essential infrastructure services only"""
|
||||
print("\n🚀 Testing essential services startup...")
|
||||
|
||||
# Start only databases and infrastructure - not the application services
|
||||
essential_services = [
|
||||
"redis",
|
||||
"rabbitmq",
|
||||
"auth-db",
|
||||
"data-db"
|
||||
]
|
||||
|
||||
try:
|
||||
# Stop any running containers first
|
||||
print("Stopping any existing containers...")
|
||||
run_command("docker compose down", timeout=120)
|
||||
|
||||
# Start essential services
|
||||
services_str = " ".join(essential_services)
|
||||
cmd = f"docker compose up -d {services_str}"
|
||||
result = run_command(cmd, timeout=300)
|
||||
|
||||
if result.returncode != 0:
|
||||
print("❌ Failed to start essential services")
|
||||
if result.stderr:
|
||||
print(f"Error: {result.stderr}")
|
||||
return False
|
||||
|
||||
# Wait for services to be ready
|
||||
print("Waiting for services to be ready...")
|
||||
time.sleep(30)
|
||||
|
||||
# Check service health
|
||||
print("Checking service health...")
|
||||
result = run_command("docker compose ps")
|
||||
if result.returncode == 0:
|
||||
print("Services status:")
|
||||
print(result.stdout)
|
||||
|
||||
print("✅ Essential services started successfully")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Error during essential services test: {e}")
|
||||
return False
|
||||
finally:
|
||||
# Cleanup
|
||||
print("Cleaning up essential services test...")
|
||||
run_command("docker compose down", timeout=120)
|
||||
|
||||
def cleanup_docker_resources():
|
||||
"""Clean up Docker resources"""
|
||||
print("\n🧹 Cleaning up Docker resources...")
|
||||
|
||||
# Remove test images
|
||||
services = ["auth-service", "tenant-service", "training-service",
|
||||
"forecasting-service", "data-service", "notification-service"]
|
||||
|
||||
for service in services:
|
||||
run_command(f"docker rmi bakery/{service}:test", timeout=30)
|
||||
|
||||
# Remove dangling images
|
||||
run_command("docker image prune -f", timeout=60)
|
||||
|
||||
print("✅ Docker resources cleaned up")
|
||||
|
||||
def main():
|
||||
"""Main test function"""
|
||||
print("🔍 DOCKER BUILD AND COMPOSE TESTING")
|
||||
print("=" * 50)
|
||||
|
||||
base_path = Path(__file__).parent
|
||||
os.chdir(base_path)
|
||||
|
||||
# Test results
|
||||
test_results = {
|
||||
"docker_available": False,
|
||||
"compose_config": False,
|
||||
"individual_builds": {},
|
||||
"essential_services": False
|
||||
}
|
||||
|
||||
# Check Docker availability
|
||||
test_results["docker_available"] = check_docker_available()
|
||||
if not test_results["docker_available"]:
|
||||
print("\n❌ Docker tests cannot proceed without Docker")
|
||||
return 1
|
||||
|
||||
# Test docker-compose configuration
|
||||
test_results["compose_config"] = test_docker_compose_config()
|
||||
|
||||
# Test individual builds (this might take a while)
|
||||
print("\n⚠️ Individual builds test can take several minutes...")
|
||||
user_input = input("Run individual service builds? (y/N): ").lower()
|
||||
|
||||
if user_input == 'y':
|
||||
test_results["individual_builds"] = test_individual_builds()
|
||||
else:
|
||||
print("Skipping individual builds test")
|
||||
test_results["individual_builds"] = {"skipped": True}
|
||||
|
||||
# Test essential services startup
|
||||
print("\n⚠️ Essential services test will start Docker containers...")
|
||||
user_input = input("Test essential services startup? (y/N): ").lower()
|
||||
|
||||
if user_input == 'y':
|
||||
test_results["essential_services"] = test_essential_services_startup()
|
||||
else:
|
||||
print("Skipping essential services test")
|
||||
test_results["essential_services"] = "skipped"
|
||||
|
||||
# Print final results
|
||||
print("\n" + "=" * 50)
|
||||
print("📋 TEST RESULTS SUMMARY")
|
||||
print("=" * 50)
|
||||
|
||||
print(f"Docker Available: {'✅' if test_results['docker_available'] else '❌'}")
|
||||
print(f"Docker Compose Config: {'✅' if test_results['compose_config'] else '❌'}")
|
||||
|
||||
if "skipped" in test_results["individual_builds"]:
|
||||
print("Individual Builds: ⏭️ Skipped")
|
||||
else:
|
||||
builds = test_results["individual_builds"]
|
||||
success_count = sum(1 for v in builds.values() if v)
|
||||
total_count = len(builds)
|
||||
print(f"Individual Builds: {success_count}/{total_count} ✅")
|
||||
|
||||
for service, success in builds.items():
|
||||
status = "✅" if success else "❌"
|
||||
print(f" - {service}: {status}")
|
||||
|
||||
if test_results["essential_services"] == "skipped":
|
||||
print("Essential Services: ⏭️ Skipped")
|
||||
else:
|
||||
print(f"Essential Services: {'✅' if test_results['essential_services'] else '❌'}")
|
||||
|
||||
# Cleanup
|
||||
if user_input == 'y' and "skipped" not in test_results["individual_builds"]:
|
||||
cleanup_docker_resources()
|
||||
|
||||
# Determine overall success
|
||||
if (test_results["docker_available"] and
|
||||
test_results["compose_config"] and
|
||||
(test_results["individual_builds"] == {"skipped": True} or
|
||||
all(test_results["individual_builds"].values())) and
|
||||
(test_results["essential_services"] == "skipped" or test_results["essential_services"])):
|
||||
print("\n🎉 ALL TESTS PASSED")
|
||||
return 0
|
||||
else:
|
||||
print("\n❌ SOME TESTS FAILED")
|
||||
return 1
|
||||
|
||||
if __name__ == "__main__":
|
||||
exit_code = main()
|
||||
sys.exit(exit_code)
|
||||
@@ -1,300 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Automated Docker Build and Compose Test Script
|
||||
Tests that each service can be built correctly and docker-compose starts without errors
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import subprocess
|
||||
import time
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
def run_command(cmd, cwd=None, timeout=300, capture_output=True):
|
||||
"""Run a shell command with timeout and error handling"""
|
||||
try:
|
||||
print(f"Running: {cmd}")
|
||||
if capture_output:
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
shell=True,
|
||||
cwd=cwd,
|
||||
timeout=timeout,
|
||||
capture_output=True,
|
||||
text=True
|
||||
)
|
||||
else:
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
shell=True,
|
||||
cwd=cwd,
|
||||
timeout=timeout
|
||||
)
|
||||
return result
|
||||
except subprocess.TimeoutExpired:
|
||||
print(f"Command timed out after {timeout} seconds: {cmd}")
|
||||
return None
|
||||
except Exception as e:
|
||||
print(f"Error running command: {e}")
|
||||
return None
|
||||
|
||||
def check_docker_available():
|
||||
"""Check if Docker is available and running"""
|
||||
print("🐳 Checking Docker availability...")
|
||||
|
||||
# Check if docker command exists
|
||||
result = run_command("which docker")
|
||||
if result.returncode != 0:
|
||||
print("❌ Docker command not found. Please install Docker.")
|
||||
return False
|
||||
|
||||
# Check if Docker daemon is running
|
||||
result = run_command("docker version")
|
||||
if result.returncode != 0:
|
||||
print("❌ Docker daemon is not running. Please start Docker.")
|
||||
return False
|
||||
|
||||
# Check if docker-compose is available
|
||||
result = run_command("docker compose version")
|
||||
if result.returncode != 0:
|
||||
# Try legacy docker-compose
|
||||
result = run_command("docker-compose version")
|
||||
if result.returncode != 0:
|
||||
print("❌ docker-compose not found. Please install docker-compose.")
|
||||
return False
|
||||
else:
|
||||
print("✅ Using legacy docker-compose")
|
||||
else:
|
||||
print("✅ Using Docker Compose v2")
|
||||
|
||||
print("✅ Docker is available and running")
|
||||
return True
|
||||
|
||||
def test_docker_compose_config():
|
||||
"""Test docker-compose configuration"""
|
||||
print("\n📋 Testing docker-compose configuration...")
|
||||
|
||||
# Check if docker-compose.yml exists
|
||||
if not os.path.exists("docker-compose.yml"):
|
||||
print("❌ docker-compose.yml not found")
|
||||
return False
|
||||
|
||||
# Check if .env file exists
|
||||
if not os.path.exists(".env"):
|
||||
print("❌ .env file not found")
|
||||
return False
|
||||
|
||||
# Validate docker-compose configuration
|
||||
result = run_command("docker compose config")
|
||||
if result.returncode != 0:
|
||||
# Try legacy docker-compose
|
||||
result = run_command("docker-compose config")
|
||||
if result.returncode != 0:
|
||||
print("❌ docker-compose configuration validation failed")
|
||||
if result.stderr:
|
||||
print(f"Error: {result.stderr}")
|
||||
return False
|
||||
|
||||
print("✅ docker-compose configuration is valid")
|
||||
return True
|
||||
|
||||
def test_dockerfile_syntax():
|
||||
"""Test that each Dockerfile has valid syntax"""
|
||||
print("\n📄 Testing Dockerfile syntax...")
|
||||
|
||||
services = [
|
||||
("auth", "services/auth/Dockerfile"),
|
||||
("tenant", "services/tenant/Dockerfile"),
|
||||
("training", "services/training/Dockerfile"),
|
||||
("forecasting", "services/forecasting/Dockerfile"),
|
||||
("data", "services/data/Dockerfile"),
|
||||
("notification", "services/notification/Dockerfile")
|
||||
]
|
||||
|
||||
dockerfile_results = {}
|
||||
|
||||
for service_name, dockerfile_path in services:
|
||||
print(f"\n--- Checking {service_name} Dockerfile ---")
|
||||
|
||||
if not os.path.exists(dockerfile_path):
|
||||
print(f"❌ Dockerfile not found: {dockerfile_path}")
|
||||
dockerfile_results[service_name] = False
|
||||
continue
|
||||
|
||||
# Check Dockerfile syntax using docker build --dry-run (if available)
|
||||
# Otherwise just check if file exists and is readable
|
||||
try:
|
||||
with open(dockerfile_path, 'r') as f:
|
||||
content = f.read()
|
||||
if 'FROM' not in content:
|
||||
print(f"❌ {service_name} Dockerfile missing FROM instruction")
|
||||
dockerfile_results[service_name] = False
|
||||
elif 'WORKDIR' not in content:
|
||||
print(f"⚠️ {service_name} Dockerfile missing WORKDIR instruction")
|
||||
dockerfile_results[service_name] = True
|
||||
else:
|
||||
print(f"✅ {service_name} Dockerfile syntax looks good")
|
||||
dockerfile_results[service_name] = True
|
||||
except Exception as e:
|
||||
print(f"❌ Error reading {service_name} Dockerfile: {e}")
|
||||
dockerfile_results[service_name] = False
|
||||
|
||||
return dockerfile_results
|
||||
|
||||
def check_requirements_files():
|
||||
"""Check that requirements.txt files exist for each service"""
|
||||
print("\n📦 Checking requirements.txt files...")
|
||||
|
||||
services = ["auth", "tenant", "training", "forecasting", "data", "notification"]
|
||||
requirements_results = {}
|
||||
|
||||
for service in services:
|
||||
req_path = f"services/{service}/requirements.txt"
|
||||
if os.path.exists(req_path):
|
||||
print(f"✅ {service} requirements.txt found")
|
||||
requirements_results[service] = True
|
||||
else:
|
||||
print(f"❌ {service} requirements.txt missing")
|
||||
requirements_results[service] = False
|
||||
|
||||
return requirements_results
|
||||
|
||||
def test_service_main_files():
|
||||
"""Check that each service has a main.py file"""
|
||||
print("\n🐍 Checking service main.py files...")
|
||||
|
||||
services = ["auth", "tenant", "training", "forecasting", "data", "notification"]
|
||||
main_file_results = {}
|
||||
|
||||
for service in services:
|
||||
main_path = f"services/{service}/app/main.py"
|
||||
if os.path.exists(main_path):
|
||||
try:
|
||||
with open(main_path, 'r') as f:
|
||||
content = f.read()
|
||||
if 'FastAPI' in content or 'app = ' in content:
|
||||
print(f"✅ {service} main.py looks good")
|
||||
main_file_results[service] = True
|
||||
else:
|
||||
print(f"⚠️ {service} main.py exists but may not be a FastAPI app")
|
||||
main_file_results[service] = True
|
||||
except Exception as e:
|
||||
print(f"❌ Error reading {service} main.py: {e}")
|
||||
main_file_results[service] = False
|
||||
else:
|
||||
print(f"❌ {service} main.py missing")
|
||||
main_file_results[service] = False
|
||||
|
||||
return main_file_results
|
||||
|
||||
def quick_build_test():
|
||||
"""Quick test to see if one service can build (faster than full build)"""
|
||||
print("\n⚡ Quick build test (data service only)...")
|
||||
|
||||
# Test building just the data service
|
||||
cmd = "docker build --no-cache -t bakery/data-service:quick-test -f services/data/Dockerfile ."
|
||||
result = run_command(cmd, timeout=600)
|
||||
|
||||
if result and result.returncode == 0:
|
||||
print("✅ Data service quick build successful")
|
||||
# Cleanup
|
||||
run_command("docker rmi bakery/data-service:quick-test", timeout=30)
|
||||
return True
|
||||
else:
|
||||
print("❌ Data service quick build failed")
|
||||
if result and result.stderr:
|
||||
print(f"Build error: {result.stderr[-1000:]}") # Last 1000 chars
|
||||
return False
|
||||
|
||||
def main():
|
||||
"""Main test function"""
|
||||
print("🔍 AUTOMATED DOCKER BUILD AND COMPOSE TESTING")
|
||||
print("=" * 60)
|
||||
|
||||
base_path = Path(__file__).parent
|
||||
os.chdir(base_path)
|
||||
|
||||
# Test results
|
||||
test_results = {
|
||||
"docker_available": False,
|
||||
"compose_config": False,
|
||||
"dockerfile_syntax": {},
|
||||
"requirements_files": {},
|
||||
"main_files": {},
|
||||
"quick_build": False
|
||||
}
|
||||
|
||||
# Check Docker availability
|
||||
test_results["docker_available"] = check_docker_available()
|
||||
if not test_results["docker_available"]:
|
||||
print("\n❌ Docker tests cannot proceed without Docker")
|
||||
return 1
|
||||
|
||||
# Test docker-compose configuration
|
||||
test_results["compose_config"] = test_docker_compose_config()
|
||||
|
||||
# Test Dockerfile syntax
|
||||
test_results["dockerfile_syntax"] = test_dockerfile_syntax()
|
||||
|
||||
# Check requirements files
|
||||
test_results["requirements_files"] = check_requirements_files()
|
||||
|
||||
# Check main.py files
|
||||
test_results["main_files"] = test_service_main_files()
|
||||
|
||||
# Quick build test
|
||||
test_results["quick_build"] = quick_build_test()
|
||||
|
||||
# Print final results
|
||||
print("\n" + "=" * 60)
|
||||
print("📋 TEST RESULTS SUMMARY")
|
||||
print("=" * 60)
|
||||
|
||||
print(f"Docker Available: {'✅' if test_results['docker_available'] else '❌'}")
|
||||
print(f"Docker Compose Config: {'✅' if test_results['compose_config'] else '❌'}")
|
||||
|
||||
# Dockerfile syntax results
|
||||
dockerfile_success = all(test_results["dockerfile_syntax"].values())
|
||||
print(f"Dockerfile Syntax: {'✅' if dockerfile_success else '❌'}")
|
||||
for service, success in test_results["dockerfile_syntax"].items():
|
||||
status = "✅" if success else "❌"
|
||||
print(f" - {service}: {status}")
|
||||
|
||||
# Requirements files results
|
||||
req_success = all(test_results["requirements_files"].values())
|
||||
print(f"Requirements Files: {'✅' if req_success else '❌'}")
|
||||
for service, success in test_results["requirements_files"].items():
|
||||
status = "✅" if success else "❌"
|
||||
print(f" - {service}: {status}")
|
||||
|
||||
# Main files results
|
||||
main_success = all(test_results["main_files"].values())
|
||||
print(f"Main.py Files: {'✅' if main_success else '❌'}")
|
||||
for service, success in test_results["main_files"].items():
|
||||
status = "✅" if success else "❌"
|
||||
print(f" - {service}: {status}")
|
||||
|
||||
print(f"Quick Build Test: {'✅' if test_results['quick_build'] else '❌'}")
|
||||
|
||||
# Determine overall success
|
||||
overall_success = (
|
||||
test_results["docker_available"] and
|
||||
test_results["compose_config"] and
|
||||
dockerfile_success and
|
||||
req_success and
|
||||
main_success and
|
||||
test_results["quick_build"]
|
||||
)
|
||||
|
||||
if overall_success:
|
||||
print("\n🎉 ALL TESTS PASSED - Services should build and start correctly!")
|
||||
print("💡 You can now run: docker compose up -d")
|
||||
return 0
|
||||
else:
|
||||
print("\n❌ SOME TESTS FAILED - Please fix issues before running docker compose")
|
||||
return 1
|
||||
|
||||
if __name__ == "__main__":
|
||||
exit_code = main()
|
||||
sys.exit(exit_code)
|
||||
@@ -1,151 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Simple Docker Services Test
|
||||
Tests that docker-compose can start services without external dependencies
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import subprocess
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
def run_command(cmd, timeout=300):
|
||||
"""Run a shell command with timeout"""
|
||||
try:
|
||||
print(f"Running: {cmd}")
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
shell=True,
|
||||
timeout=timeout,
|
||||
capture_output=True,
|
||||
text=True
|
||||
)
|
||||
return result
|
||||
except subprocess.TimeoutExpired:
|
||||
print(f"Command timed out after {timeout} seconds")
|
||||
return None
|
||||
except Exception as e:
|
||||
print(f"Error running command: {e}")
|
||||
return None
|
||||
|
||||
def test_infrastructure_services():
|
||||
"""Test starting just infrastructure services"""
|
||||
print("🏗️ Testing infrastructure services...")
|
||||
|
||||
try:
|
||||
# Stop any existing containers
|
||||
print("Cleaning up existing containers...")
|
||||
run_command("docker compose down", timeout=120)
|
||||
|
||||
# Start only infrastructure services
|
||||
infra_services = "redis rabbitmq auth-db data-db"
|
||||
cmd = f"docker compose up -d {infra_services}"
|
||||
result = run_command(cmd, timeout=300)
|
||||
|
||||
if result and result.returncode == 0:
|
||||
print("✅ Infrastructure services started")
|
||||
|
||||
# Wait a bit for services to initialize
|
||||
print("Waiting 30 seconds for services to initialize...")
|
||||
time.sleep(30)
|
||||
|
||||
# Check container status
|
||||
status_result = run_command("docker compose ps", timeout=30)
|
||||
if status_result and status_result.stdout:
|
||||
print("Container status:")
|
||||
print(status_result.stdout)
|
||||
|
||||
# Try to start one application service
|
||||
print("Testing application service startup...")
|
||||
app_result = run_command("docker compose up -d auth-service", timeout=180)
|
||||
|
||||
if app_result and app_result.returncode == 0:
|
||||
print("✅ Auth service started successfully")
|
||||
|
||||
# Wait for it to initialize
|
||||
time.sleep(20)
|
||||
|
||||
# Check health with curl
|
||||
health_result = run_command("curl -f http://localhost:8001/health", timeout=10)
|
||||
if health_result and health_result.returncode == 0:
|
||||
print("✅ Auth service is healthy!")
|
||||
return True
|
||||
else:
|
||||
print("⚠️ Auth service started but health check failed")
|
||||
# Show logs for debugging
|
||||
logs_result = run_command("docker compose logs --tail=20 auth-service", timeout=30)
|
||||
if logs_result and logs_result.stdout:
|
||||
print("Auth service logs:")
|
||||
print(logs_result.stdout)
|
||||
return False
|
||||
else:
|
||||
print("❌ Failed to start auth service")
|
||||
if app_result and app_result.stderr:
|
||||
print(f"Error: {app_result.stderr}")
|
||||
return False
|
||||
else:
|
||||
print("❌ Failed to start infrastructure services")
|
||||
if result and result.stderr:
|
||||
print(f"Error: {result.stderr}")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Error during infrastructure test: {e}")
|
||||
return False
|
||||
|
||||
def show_final_status():
|
||||
"""Show final container status"""
|
||||
print("\n📊 Final container status:")
|
||||
result = run_command("docker compose ps", timeout=30)
|
||||
if result and result.stdout:
|
||||
print(result.stdout)
|
||||
|
||||
def cleanup():
|
||||
"""Clean up containers"""
|
||||
print("\n🧹 Cleaning up containers...")
|
||||
run_command("docker compose down", timeout=180)
|
||||
print("✅ Cleanup completed")
|
||||
|
||||
def main():
|
||||
"""Main test function"""
|
||||
print("🔧 SIMPLE DOCKER SERVICES TEST")
|
||||
print("=" * 40)
|
||||
|
||||
base_path = Path(__file__).parent
|
||||
os.chdir(base_path)
|
||||
|
||||
success = False
|
||||
|
||||
try:
|
||||
# Test infrastructure services
|
||||
success = test_infrastructure_services()
|
||||
|
||||
# Show current status
|
||||
show_final_status()
|
||||
|
||||
except KeyboardInterrupt:
|
||||
print("\n⚠️ Test interrupted by user")
|
||||
except Exception as e:
|
||||
print(f"\n❌ Unexpected error: {e}")
|
||||
finally:
|
||||
# Always cleanup
|
||||
cleanup()
|
||||
|
||||
# Final result
|
||||
print("\n" + "=" * 40)
|
||||
if success:
|
||||
print("🎉 DOCKER SERVICES TEST PASSED!")
|
||||
print("✅ Services can start and respond to health checks")
|
||||
print("💡 Your docker-compose setup is working correctly")
|
||||
print("🚀 You can now run: docker compose up -d")
|
||||
return 0
|
||||
else:
|
||||
print("❌ DOCKER SERVICES TEST FAILED")
|
||||
print("⚠️ Some issues were found with service startup")
|
||||
print("💡 Check the logs above for details")
|
||||
return 1
|
||||
|
||||
if __name__ == "__main__":
|
||||
exit_code = main()
|
||||
sys.exit(exit_code)
|
||||
@@ -1,78 +0,0 @@
|
||||
#\!/bin/bash
|
||||
|
||||
echo "🧪 Testing Forecasting Service Components - FIXED ROUTES"
|
||||
echo "======================================================"
|
||||
|
||||
# Use the most recent successful tenant from the full onboarding test
|
||||
TENANT_ID="5765e61a-4d06-4e17-b614-a4f8410e2a35"
|
||||
|
||||
# Get a fresh access token
|
||||
echo "1. Getting access token..."
|
||||
LOGIN_RESPONSE=$(curl -s -X POST "http://localhost:8000/api/v1/auth/login" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"email": "onboarding.test.1754565461@bakery.com", "password": "TestPassword123\!"}')
|
||||
|
||||
ACCESS_TOKEN=$(echo "$LOGIN_RESPONSE" | python3 -c "
|
||||
import json, sys
|
||||
try:
|
||||
data = json.load(sys.stdin)
|
||||
print(data.get('access_token', ''))
|
||||
except:
|
||||
pass
|
||||
" 2>/dev/null)
|
||||
|
||||
if [ -z "$ACCESS_TOKEN" ]; then
|
||||
echo "❌ Login failed"
|
||||
echo "Response: $LOGIN_RESPONSE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✅ Access token obtained"
|
||||
|
||||
# Test 1: Test forecast endpoint with correct path format (no extra path)
|
||||
echo ""
|
||||
echo "2. Testing forecast endpoint with correct format..."
|
||||
FORECAST_RESPONSE=$(curl -s -w "\nHTTP_CODE:%{http_code}" -X POST "http://localhost:8000/api/v1/tenants/$TENANT_ID/forecasts" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer $ACCESS_TOKEN" \
|
||||
-d '{"product_name": "Cafe", "days_ahead": 7}')
|
||||
|
||||
HTTP_CODE=$(echo "$FORECAST_RESPONSE" | grep "HTTP_CODE:" | cut -d: -f2)
|
||||
FORECAST_RESPONSE=$(echo "$FORECAST_RESPONSE" | sed '/HTTP_CODE:/d')
|
||||
|
||||
echo "Forecast HTTP Code: $HTTP_CODE"
|
||||
echo "Forecast Response:"
|
||||
echo "$FORECAST_RESPONSE" | python3 -m json.tool 2>/dev/null || echo "$FORECAST_RESPONSE"
|
||||
|
||||
# Test 2: Test predictions endpoint with correct format
|
||||
echo ""
|
||||
echo "3. Testing predictions endpoint with correct format..."
|
||||
PREDICTION_RESPONSE=$(curl -s -w "\nHTTP_CODE:%{http_code}" -X POST "http://localhost:8000/api/v1/tenants/$TENANT_ID/predictions" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer $ACCESS_TOKEN" \
|
||||
-d '{"product_names": ["Cafe", "Pan"], "days_ahead": 5, "include_confidence": true}')
|
||||
|
||||
PRED_HTTP_CODE=$(echo "$PREDICTION_RESPONSE" | grep "HTTP_CODE:" | cut -d: -f2)
|
||||
PREDICTION_RESPONSE=$(echo "$PREDICTION_RESPONSE" | sed '/HTTP_CODE:/d')
|
||||
|
||||
echo "Prediction HTTP Code: $PRED_HTTP_CODE"
|
||||
echo "Prediction Response:"
|
||||
echo "$PREDICTION_RESPONSE" | python3 -m json.tool 2>/dev/null || echo "$PREDICTION_RESPONSE"
|
||||
|
||||
# Test 3: Direct forecasting service test (bypass gateway)
|
||||
echo ""
|
||||
echo "4. Testing forecasting service directly (bypass gateway)..."
|
||||
DIRECT_FORECAST=$(curl -s -w "\nHTTP_CODE:%{http_code}" -X POST "http://localhost:8003/api/v1/tenants/$TENANT_ID/forecasts" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer $ACCESS_TOKEN" \
|
||||
-d '{"product_name": "Cafe", "days_ahead": 7}')
|
||||
|
||||
DIRECT_HTTP_CODE=$(echo "$DIRECT_FORECAST" | grep "HTTP_CODE:" | cut -d: -f2)
|
||||
DIRECT_FORECAST=$(echo "$DIRECT_FORECAST" | sed '/HTTP_CODE:/d')
|
||||
|
||||
echo "Direct Forecast HTTP Code: $DIRECT_HTTP_CODE"
|
||||
echo "Direct Forecast Response:"
|
||||
echo "$DIRECT_FORECAST" | python3 -m json.tool 2>/dev/null || echo "$DIRECT_FORECAST"
|
||||
|
||||
echo ""
|
||||
echo "🏁 Fixed forecasting test completed\!"
|
||||
@@ -1,83 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
echo "🧪 Testing Forecasting Service Components"
|
||||
echo "========================================"
|
||||
|
||||
# Use the most recent successful tenant from the full onboarding test
|
||||
TENANT_ID="5765e61a-4d06-4e17-b614-a4f8410e2a35"
|
||||
|
||||
# Get a fresh access token
|
||||
echo "1. Getting access token..."
|
||||
LOGIN_RESPONSE=$(curl -s -X POST "http://localhost:8000/api/v1/auth/login" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"email": "onboarding.test.1754565461@bakery.com", "password": "TestPassword123!"}')
|
||||
|
||||
ACCESS_TOKEN=$(echo "$LOGIN_RESPONSE" | python3 -c "
|
||||
import json, sys
|
||||
try:
|
||||
data = json.load(sys.stdin)
|
||||
print(data.get('access_token', ''))
|
||||
except:
|
||||
pass
|
||||
" 2>/dev/null)
|
||||
|
||||
if [ -z "$ACCESS_TOKEN" ]; then
|
||||
echo "❌ Login failed"
|
||||
echo "Response: $LOGIN_RESPONSE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✅ Access token obtained"
|
||||
|
||||
# Test 1: Check forecasting service health
|
||||
echo ""
|
||||
echo "2. Testing forecasting service health..."
|
||||
FORECAST_HEALTH=$(curl -s "http://localhost:8003/health")
|
||||
echo "Forecasting Service Health:"
|
||||
echo "$FORECAST_HEALTH" | python3 -m json.tool 2>/dev/null || echo "$FORECAST_HEALTH"
|
||||
|
||||
# Test 2: Test forecast endpoint (should handle gracefully if no models exist)
|
||||
echo ""
|
||||
echo "3. Testing forecast endpoint..."
|
||||
FORECAST_RESPONSE=$(curl -s -w "\nHTTP_CODE:%{http_code}" -X POST "http://localhost:8000/api/v1/tenants/$TENANT_ID/forecasts" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer $ACCESS_TOKEN" \
|
||||
-d '{"product_name": "bread", "days_ahead": 7}')
|
||||
|
||||
HTTP_CODE=$(echo "$FORECAST_RESPONSE" | grep "HTTP_CODE:" | cut -d: -f2)
|
||||
FORECAST_RESPONSE=$(echo "$FORECAST_RESPONSE" | sed '/HTTP_CODE:/d')
|
||||
|
||||
echo "Forecast HTTP Code: $HTTP_CODE"
|
||||
echo "Forecast Response:"
|
||||
echo "$FORECAST_RESPONSE" | python3 -m json.tool 2>/dev/null || echo "$FORECAST_RESPONSE"
|
||||
|
||||
# Test 3: Test predictions endpoint
|
||||
echo ""
|
||||
echo "4. Testing predictions endpoint..."
|
||||
PREDICTION_RESPONSE=$(curl -s -w "\nHTTP_CODE:%{http_code}" -X POST "http://localhost:8000/api/v1/tenants/$TENANT_ID/predictions" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer $ACCESS_TOKEN" \
|
||||
-d '{"product_names": ["bread", "croissant"], "days_ahead": 5, "include_confidence": true}')
|
||||
|
||||
PRED_HTTP_CODE=$(echo "$PREDICTION_RESPONSE" | grep "HTTP_CODE:" | cut -d: -f2)
|
||||
PREDICTION_RESPONSE=$(echo "$PREDICTION_RESPONSE" | sed '/HTTP_CODE:/d')
|
||||
|
||||
echo "Prediction HTTP Code: $PRED_HTTP_CODE"
|
||||
echo "Prediction Response:"
|
||||
echo "$PREDICTION_RESPONSE" | python3 -m json.tool 2>/dev/null || echo "$PREDICTION_RESPONSE"
|
||||
|
||||
# Test 4: Check available products for this tenant
|
||||
echo ""
|
||||
echo "5. Testing available products endpoint..."
|
||||
PRODUCTS_RESPONSE=$(curl -s -w "\nHTTP_CODE:%{http_code}" -X GET "http://localhost:8000/api/v1/tenants/$TENANT_ID/sales/products" \
|
||||
-H "Authorization: Bearer $ACCESS_TOKEN")
|
||||
|
||||
PROD_HTTP_CODE=$(echo "$PRODUCTS_RESPONSE" | grep "HTTP_CODE:" | cut -d: -f2)
|
||||
PRODUCTS_RESPONSE=$(echo "$PRODUCTS_RESPONSE" | sed '/HTTP_CODE:/d')
|
||||
|
||||
echo "Products HTTP Code: $PROD_HTTP_CODE"
|
||||
echo "Available Products:"
|
||||
echo "$PRODUCTS_RESPONSE" | python3 -m json.tool 2>/dev/null || echo "$PRODUCTS_RESPONSE"
|
||||
|
||||
echo ""
|
||||
echo "🏁 Forecasting service component test completed!"
|
||||
@@ -1,645 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Frontend API Simulation Test
|
||||
*
|
||||
* This script simulates how the frontend would interact with the backend APIs
|
||||
* using the exact same patterns defined in the frontend/src/api structure.
|
||||
*
|
||||
* Purpose:
|
||||
* - Verify frontend API abstraction aligns with backend endpoints
|
||||
* - Test onboarding flow using frontend API patterns
|
||||
* - Identify any mismatches between frontend expectations and backend reality
|
||||
*/
|
||||
|
||||
const https = require('https');
|
||||
const http = require('http');
|
||||
const { URL } = require('url');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// Frontend API Configuration (from frontend/src/api/client/config.ts)
|
||||
const API_CONFIG = {
|
||||
baseURL: 'http://localhost:8000/api/v1', // Using API Gateway
|
||||
timeout: 30000,
|
||||
retries: 3,
|
||||
retryDelay: 1000,
|
||||
};
|
||||
|
||||
// Service Endpoints (from frontend/src/api/client/config.ts)
|
||||
const SERVICE_ENDPOINTS = {
|
||||
auth: '/auth',
|
||||
tenant: '/tenants',
|
||||
data: '/tenants', // Data operations are tenant-scoped
|
||||
training: '/tenants', // Training operations are tenant-scoped
|
||||
forecasting: '/tenants', // Forecasting operations are tenant-scoped
|
||||
notification: '/tenants', // Notification operations are tenant-scoped
|
||||
};
|
||||
|
||||
// Colors for console output
|
||||
const colors = {
|
||||
reset: '\x1b[0m',
|
||||
bright: '\x1b[1m',
|
||||
red: '\x1b[31m',
|
||||
green: '\x1b[32m',
|
||||
yellow: '\x1b[33m',
|
||||
blue: '\x1b[34m',
|
||||
magenta: '\x1b[35m',
|
||||
cyan: '\x1b[36m',
|
||||
};
|
||||
|
||||
function log(color, message, ...args) {
|
||||
console.log(`${colors[color]}${message}${colors.reset}`, ...args);
|
||||
}
|
||||
|
||||
// HTTP Client Implementation (mimicking frontend apiClient)
|
||||
class ApiClient {
|
||||
constructor(baseURL = API_CONFIG.baseURL) {
|
||||
this.baseURL = baseURL;
|
||||
this.defaultHeaders = {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
'User-Agent': 'Frontend-API-Simulation/1.0',
|
||||
};
|
||||
this.authToken = null;
|
||||
}
|
||||
|
||||
setAuthToken(token) {
|
||||
this.authToken = token;
|
||||
}
|
||||
|
||||
async request(endpoint, options = {}) {
|
||||
// Properly construct full URL by joining base URL and endpoint
|
||||
const fullUrl = this.baseURL + endpoint;
|
||||
const url = new URL(fullUrl);
|
||||
const isHttps = url.protocol === 'https:';
|
||||
const client = isHttps ? https : http;
|
||||
|
||||
const headers = {
|
||||
...this.defaultHeaders,
|
||||
...options.headers,
|
||||
};
|
||||
|
||||
if (this.authToken) {
|
||||
headers['Authorization'] = `Bearer ${this.authToken}`;
|
||||
}
|
||||
|
||||
let bodyString = null;
|
||||
if (options.body) {
|
||||
bodyString = JSON.stringify(options.body);
|
||||
headers['Content-Length'] = Buffer.byteLength(bodyString, 'utf8');
|
||||
}
|
||||
|
||||
const requestOptions = {
|
||||
method: options.method || 'GET',
|
||||
headers,
|
||||
timeout: options.timeout || API_CONFIG.timeout,
|
||||
};
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = client.request(url, requestOptions, (res) => {
|
||||
let data = '';
|
||||
|
||||
res.on('data', (chunk) => {
|
||||
data += chunk;
|
||||
});
|
||||
|
||||
res.on('end', () => {
|
||||
try {
|
||||
const parsedData = data ? JSON.parse(data) : {};
|
||||
|
||||
if (res.statusCode >= 200 && res.statusCode < 300) {
|
||||
resolve(parsedData);
|
||||
} else {
|
||||
reject(new Error(`HTTP ${res.statusCode}: ${JSON.stringify(parsedData)}`));
|
||||
}
|
||||
} catch (e) {
|
||||
if (res.statusCode >= 200 && res.statusCode < 300) {
|
||||
resolve(data);
|
||||
} else {
|
||||
reject(new Error(`HTTP ${res.statusCode}: ${data}`));
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
req.on('error', (error) => {
|
||||
reject(error);
|
||||
});
|
||||
|
||||
req.on('timeout', () => {
|
||||
req.destroy();
|
||||
reject(new Error('Request timeout'));
|
||||
});
|
||||
|
||||
if (bodyString) {
|
||||
req.write(bodyString);
|
||||
}
|
||||
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
async get(endpoint, options = {}) {
|
||||
const fullUrl = this.baseURL + endpoint;
|
||||
const url = new URL(fullUrl);
|
||||
if (options.params) {
|
||||
Object.entries(options.params).forEach(([key, value]) => {
|
||||
if (value !== undefined && value !== null) {
|
||||
url.searchParams.append(key, value);
|
||||
}
|
||||
});
|
||||
}
|
||||
return this.request(endpoint + (url.search || ''), { ...options, method: 'GET' });
|
||||
}
|
||||
|
||||
async post(endpoint, data, options = {}) {
|
||||
return this.request(endpoint, { ...options, method: 'POST', body: data });
|
||||
}
|
||||
|
||||
async put(endpoint, data, options = {}) {
|
||||
return this.request(endpoint, { ...options, method: 'PUT', body: data });
|
||||
}
|
||||
|
||||
async patch(endpoint, data, options = {}) {
|
||||
return this.request(endpoint, { ...options, method: 'PATCH', body: data });
|
||||
}
|
||||
|
||||
async delete(endpoint, options = {}) {
|
||||
return this.request(endpoint, { ...options, method: 'DELETE' });
|
||||
}
|
||||
}
|
||||
|
||||
// Frontend Service Implementations
|
||||
class AuthService {
|
||||
constructor(apiClient) {
|
||||
this.apiClient = apiClient;
|
||||
this.baseEndpoint = SERVICE_ENDPOINTS.auth;
|
||||
}
|
||||
|
||||
async register(data) {
|
||||
log('blue', '📋 Frontend AuthService.register() called with:', JSON.stringify(data, null, 2));
|
||||
return this.apiClient.post(`${this.baseEndpoint}/register`, data);
|
||||
}
|
||||
|
||||
async login(credentials) {
|
||||
log('blue', '🔐 Frontend AuthService.login() called with:', { email: credentials.email, password: '[HIDDEN]' });
|
||||
return this.apiClient.post(`${this.baseEndpoint}/login`, credentials);
|
||||
}
|
||||
|
||||
async getCurrentUser() {
|
||||
log('blue', '👤 Frontend AuthService.getCurrentUser() called');
|
||||
return this.apiClient.get('/users/me');
|
||||
}
|
||||
}
|
||||
|
||||
class TenantService {
|
||||
constructor(apiClient) {
|
||||
this.apiClient = apiClient;
|
||||
this.baseEndpoint = SERVICE_ENDPOINTS.tenant;
|
||||
}
|
||||
|
||||
async createTenant(data) {
|
||||
log('blue', '🏪 Frontend TenantService.createTenant() called with:', JSON.stringify(data, null, 2));
|
||||
return this.apiClient.post(`${this.baseEndpoint}/register`, data);
|
||||
}
|
||||
|
||||
async getTenant(tenantId) {
|
||||
log('blue', `🏪 Frontend TenantService.getTenant(${tenantId}) called`);
|
||||
return this.apiClient.get(`${this.baseEndpoint}/${tenantId}`);
|
||||
}
|
||||
}
|
||||
|
||||
class DataService {
|
||||
constructor(apiClient) {
|
||||
this.apiClient = apiClient;
|
||||
}
|
||||
|
||||
async validateSalesData(tenantId, data, dataFormat = 'csv') {
|
||||
log('blue', `📊 Frontend DataService.validateSalesData(${tenantId}) called`);
|
||||
const requestData = {
|
||||
data: data,
|
||||
data_format: dataFormat,
|
||||
validate_only: true,
|
||||
source: 'onboarding_upload'
|
||||
};
|
||||
return this.apiClient.post(`/tenants/${tenantId}/sales/import/validate-json`, requestData);
|
||||
}
|
||||
|
||||
async uploadSalesHistory(tenantId, data, additionalData = {}) {
|
||||
log('blue', `📊 Frontend DataService.uploadSalesHistory(${tenantId}) called`);
|
||||
|
||||
// Create a mock file-like object for upload endpoint
|
||||
const mockFile = {
|
||||
name: 'bakery_sales.csv',
|
||||
size: data.length,
|
||||
type: 'text/csv'
|
||||
};
|
||||
|
||||
const formData = {
|
||||
file_format: additionalData.file_format || 'csv',
|
||||
source: additionalData.source || 'onboarding_upload',
|
||||
...additionalData
|
||||
};
|
||||
|
||||
log('blue', `📊 Making request to /tenants/${tenantId}/sales/import`);
|
||||
log('blue', `📊 Form data:`, formData);
|
||||
|
||||
// Use the actual import endpoint that the frontend uses
|
||||
return this.apiClient.post(`/tenants/${tenantId}/sales/import`, {
|
||||
data: data,
|
||||
...formData
|
||||
});
|
||||
}
|
||||
|
||||
async getProductsList(tenantId) {
|
||||
log('blue', `📦 Frontend DataService.getProductsList(${tenantId}) called`);
|
||||
return this.apiClient.get(`/tenants/${tenantId}/sales/products`);
|
||||
}
|
||||
}
|
||||
|
||||
class TrainingService {
|
||||
constructor(apiClient) {
|
||||
this.apiClient = apiClient;
|
||||
}
|
||||
|
||||
async startTrainingJob(tenantId, request) {
|
||||
log('blue', `🤖 Frontend TrainingService.startTrainingJob(${tenantId}) called`);
|
||||
return this.apiClient.post(`/tenants/${tenantId}/training/jobs`, request);
|
||||
}
|
||||
|
||||
async getTrainingJobStatus(tenantId, jobId) {
|
||||
log('blue', `🤖 Frontend TrainingService.getTrainingJobStatus(${tenantId}, ${jobId}) called`);
|
||||
return this.apiClient.get(`/tenants/${tenantId}/training/jobs/${jobId}/status`);
|
||||
}
|
||||
}
|
||||
|
||||
class ForecastingService {
|
||||
constructor(apiClient) {
|
||||
this.apiClient = apiClient;
|
||||
}
|
||||
|
||||
async createForecast(tenantId, request) {
|
||||
log('blue', `🔮 Frontend ForecastingService.createForecast(${tenantId}) called`);
|
||||
|
||||
// Add location if not present (matching frontend implementation)
|
||||
const forecastRequest = {
|
||||
...request,
|
||||
location: request.location || "Madrid, Spain" // Default location
|
||||
};
|
||||
|
||||
log('blue', `🔮 Forecast request with location:`, forecastRequest);
|
||||
return this.apiClient.post(`/tenants/${tenantId}/forecasts/single`, forecastRequest);
|
||||
}
|
||||
}
|
||||
|
||||
// Main Test Runner
|
||||
class FrontendApiSimulationTest {
|
||||
constructor() {
|
||||
this.apiClient = new ApiClient();
|
||||
this.authService = new AuthService(this.apiClient);
|
||||
this.tenantService = new TenantService(this.apiClient);
|
||||
this.dataService = new DataService(this.apiClient);
|
||||
this.trainingService = new TrainingService(this.apiClient);
|
||||
this.forecastingService = new ForecastingService(this.apiClient);
|
||||
|
||||
this.testResults = {
|
||||
passed: 0,
|
||||
failed: 0,
|
||||
issues: [],
|
||||
};
|
||||
}
|
||||
|
||||
async runTest(name, testFn) {
|
||||
log('cyan', `\\n🧪 Running: ${name}`);
|
||||
try {
|
||||
await testFn();
|
||||
this.testResults.passed++;
|
||||
log('green', `✅ PASSED: ${name}`);
|
||||
} catch (error) {
|
||||
this.testResults.failed++;
|
||||
this.testResults.issues.push({ test: name, error: error.message });
|
||||
log('red', `❌ FAILED: ${name}`);
|
||||
log('red', ` Error: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async sleep(ms) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
// Load the actual CSV data (same as backend test)
|
||||
loadCsvData() {
|
||||
const csvPath = path.join(__dirname, 'bakery_sales_2023_2024.csv');
|
||||
try {
|
||||
const csvContent = fs.readFileSync(csvPath, 'utf8');
|
||||
log('green', `✅ Loaded CSV data: ${csvContent.split('\\n').length - 1} records`);
|
||||
return csvContent;
|
||||
} catch (error) {
|
||||
log('yellow', `⚠️ Could not load CSV file, using sample data`);
|
||||
return 'date,product,quantity,revenue,temperature,precipitation,is_weekend,is_holiday\\n2023-01-01,pan,149,178.8,5.2,0,True,False\\n2023-01-01,croissant,144,216.0,5.2,0,True,False';
|
||||
}
|
||||
}
|
||||
|
||||
async runOnboardingFlowTest() {
|
||||
log('bright', '🎯 FRONTEND API SIMULATION - ONBOARDING FLOW TEST');
|
||||
log('bright', '====================================================');
|
||||
|
||||
const timestamp = Date.now();
|
||||
const testEmail = `frontend.test.${timestamp}@bakery.com`;
|
||||
const csvData = this.loadCsvData();
|
||||
|
||||
let userId, tenantId, accessToken, jobId;
|
||||
|
||||
// Step 1: User Registration (Frontend Pattern)
|
||||
await this.runTest('User Registration', async () => {
|
||||
log('magenta', '\\n👤 STEP 1: USER REGISTRATION (Frontend Pattern)');
|
||||
|
||||
// This matches exactly what frontend/src/api/services/auth.service.ts does
|
||||
const registerData = {
|
||||
email: testEmail,
|
||||
password: 'TestPassword123!',
|
||||
full_name: 'Frontend Test User',
|
||||
role: 'admin'
|
||||
};
|
||||
|
||||
const response = await this.authService.register(registerData);
|
||||
|
||||
log('blue', 'Expected Frontend Response Structure:');
|
||||
log('blue', '- Should have: user.id, access_token, refresh_token, user object');
|
||||
log('blue', 'Actual Backend Response:');
|
||||
log('blue', JSON.stringify(response, null, 2));
|
||||
|
||||
// Frontend expects these fields (from frontend/src/api/types/auth.ts)
|
||||
if (!response.access_token) {
|
||||
throw new Error('Missing access_token in response');
|
||||
}
|
||||
if (!response.user || !response.user.id) {
|
||||
throw new Error('Missing user.id in response');
|
||||
}
|
||||
|
||||
userId = response.user.id;
|
||||
accessToken = response.access_token;
|
||||
this.apiClient.setAuthToken(accessToken);
|
||||
|
||||
log('green', `✅ User ID: ${userId}`);
|
||||
log('green', `✅ Access Token: ${accessToken.substring(0, 50)}...`);
|
||||
});
|
||||
|
||||
// Step 2: Bakery/Tenant Registration (Frontend Pattern)
|
||||
await this.runTest('Tenant Registration', async () => {
|
||||
log('magenta', '\\n🏪 STEP 2: TENANT REGISTRATION (Frontend Pattern)');
|
||||
|
||||
// This matches frontend/src/api/services/tenant.service.ts
|
||||
const tenantData = {
|
||||
name: `Frontend Test Bakery ${Math.floor(Math.random() * 1000)}`,
|
||||
business_type: 'bakery',
|
||||
address: 'Calle Gran Vía 123',
|
||||
city: 'Madrid',
|
||||
postal_code: '28001',
|
||||
phone: '+34600123456'
|
||||
};
|
||||
|
||||
const response = await this.tenantService.createTenant(tenantData);
|
||||
|
||||
log('blue', 'Expected Frontend Response Structure:');
|
||||
log('blue', '- Should have: id, name, owner_id, is_active, created_at');
|
||||
log('blue', 'Actual Backend Response:');
|
||||
log('blue', JSON.stringify(response, null, 2));
|
||||
|
||||
// Frontend expects these fields (from frontend/src/api/types/tenant.ts)
|
||||
if (!response.id) {
|
||||
throw new Error('Missing id in tenant response');
|
||||
}
|
||||
if (!response.name) {
|
||||
throw new Error('Missing name in tenant response');
|
||||
}
|
||||
|
||||
tenantId = response.id;
|
||||
log('green', `✅ Tenant ID: ${tenantId}`);
|
||||
});
|
||||
|
||||
// Step 3: Sales Data Validation (Frontend Pattern)
|
||||
await this.runTest('Sales Data Validation', async () => {
|
||||
log('magenta', '\\n📊 STEP 3: SALES DATA VALIDATION (Frontend Pattern)');
|
||||
|
||||
// This matches frontend/src/api/services/data.service.ts validateSalesData method
|
||||
const response = await this.dataService.validateSalesData(tenantId, csvData, 'csv');
|
||||
|
||||
log('blue', 'Expected Frontend Response Structure:');
|
||||
log('blue', '- Should have: is_valid, total_records, valid_records, errors, warnings');
|
||||
log('blue', 'Actual Backend Response:');
|
||||
log('blue', JSON.stringify(response, null, 2));
|
||||
|
||||
// Frontend expects these fields (from frontend/src/api/types/data.ts)
|
||||
if (typeof response.is_valid !== 'boolean') {
|
||||
throw new Error('Missing or invalid is_valid field');
|
||||
}
|
||||
if (typeof response.total_records !== 'number') {
|
||||
throw new Error('Missing or invalid total_records field');
|
||||
}
|
||||
|
||||
log('green', `✅ Validation passed: ${response.total_records} records`);
|
||||
});
|
||||
|
||||
// Step 4: Sales Data Import (Frontend Pattern)
|
||||
await this.runTest('Sales Data Import', async () => {
|
||||
log('magenta', '\\n📊 STEP 4: SALES DATA IMPORT (Frontend Pattern)');
|
||||
|
||||
// This matches frontend/src/api/services/data.service.ts uploadSalesHistory method
|
||||
const response = await this.dataService.uploadSalesHistory(tenantId, csvData, {
|
||||
file_format: 'csv',
|
||||
source: 'onboarding_upload'
|
||||
});
|
||||
|
||||
log('blue', 'Expected Frontend Response Structure:');
|
||||
log('blue', '- Should have: success, records_processed, records_created');
|
||||
log('blue', 'Actual Backend Response:');
|
||||
log('blue', JSON.stringify(response, null, 2));
|
||||
|
||||
// Check if this is validation or import response
|
||||
if (response.is_valid !== undefined) {
|
||||
log('yellow', '⚠️ API returned validation response instead of import response');
|
||||
log('yellow', ' This suggests the import endpoint might not match frontend expectations');
|
||||
}
|
||||
|
||||
log('green', `✅ Data processing completed`);
|
||||
});
|
||||
|
||||
// Step 5: Training Job Start (Frontend Pattern)
|
||||
await this.runTest('Training Job Start', async () => {
|
||||
log('magenta', '\\n🤖 STEP 5: TRAINING JOB START (Frontend Pattern)');
|
||||
|
||||
// This matches frontend/src/api/services/training.service.ts startTrainingJob method
|
||||
const trainingRequest = {
|
||||
location: {
|
||||
latitude: 40.4168,
|
||||
longitude: -3.7038
|
||||
},
|
||||
training_options: {
|
||||
model_type: 'prophet',
|
||||
optimization_enabled: true
|
||||
}
|
||||
};
|
||||
|
||||
const response = await this.trainingService.startTrainingJob(tenantId, trainingRequest);
|
||||
|
||||
log('blue', 'Expected Frontend Response Structure:');
|
||||
log('blue', '- Should have: job_id, tenant_id, status, message, training_results');
|
||||
log('blue', 'Actual Backend Response:');
|
||||
log('blue', JSON.stringify(response, null, 2));
|
||||
|
||||
// Frontend expects these fields (from frontend/src/api/types/training.ts)
|
||||
if (!response.job_id) {
|
||||
throw new Error('Missing job_id in training response');
|
||||
}
|
||||
if (!response.status) {
|
||||
throw new Error('Missing status in training response');
|
||||
}
|
||||
|
||||
jobId = response.job_id;
|
||||
log('green', `✅ Training Job ID: ${jobId}`);
|
||||
});
|
||||
|
||||
// Step 6: Training Status Check (Frontend Pattern)
|
||||
await this.runTest('Training Status Check', async () => {
|
||||
log('magenta', '\\n🤖 STEP 6: TRAINING STATUS CHECK (Frontend Pattern)');
|
||||
|
||||
// Wait longer for background training to initialize and create log record
|
||||
log('blue', '⏳ Waiting for background training to initialize...');
|
||||
await this.sleep(8000);
|
||||
|
||||
// This matches frontend/src/api/services/training.service.ts getTrainingJobStatus method
|
||||
const response = await this.trainingService.getTrainingJobStatus(tenantId, jobId);
|
||||
|
||||
log('blue', 'Expected Frontend Response Structure:');
|
||||
log('blue', '- Should have: job_id, status, progress, training_results');
|
||||
log('blue', 'Actual Backend Response:');
|
||||
log('blue', JSON.stringify(response, null, 2));
|
||||
|
||||
// Frontend expects these fields
|
||||
if (!response.job_id) {
|
||||
throw new Error('Missing job_id in status response');
|
||||
}
|
||||
|
||||
log('green', `✅ Training Status: ${response.status || 'unknown'}`);
|
||||
});
|
||||
|
||||
// Step 7: Product List Check (Frontend Pattern)
|
||||
await this.runTest('Products List Check', async () => {
|
||||
log('magenta', '\\n📦 STEP 7: PRODUCTS LIST CHECK (Frontend Pattern)');
|
||||
|
||||
// Wait a bit for data import to be processed
|
||||
log('blue', '⏳ Waiting for import processing...');
|
||||
await this.sleep(3000);
|
||||
|
||||
// This matches frontend/src/api/services/data.service.ts getProductsList method
|
||||
const response = await this.dataService.getProductsList(tenantId);
|
||||
|
||||
log('blue', 'Expected Frontend Response Structure:');
|
||||
log('blue', '- Should be: array of product objects with product_name field');
|
||||
log('blue', 'Actual Backend Response:');
|
||||
log('blue', JSON.stringify(response, null, 2));
|
||||
|
||||
// Frontend expects array of products
|
||||
let products = [];
|
||||
if (Array.isArray(response)) {
|
||||
products = response;
|
||||
} else if (response && typeof response === 'object') {
|
||||
// Handle object response format
|
||||
products = Object.values(response);
|
||||
}
|
||||
|
||||
if (products.length === 0) {
|
||||
throw new Error('No products found in response');
|
||||
}
|
||||
|
||||
log('green', `✅ Found ${products.length} products`);
|
||||
});
|
||||
|
||||
// Step 8: Forecast Creation Test (Frontend Pattern)
|
||||
await this.runTest('Forecast Creation Test', async () => {
|
||||
log('magenta', '\\n🔮 STEP 8: FORECAST CREATION TEST (Frontend Pattern)');
|
||||
|
||||
// This matches frontend/src/api/services/forecasting.service.ts pattern
|
||||
const forecastRequest = {
|
||||
product_name: 'pan',
|
||||
forecast_date: '2025-08-08',
|
||||
forecast_days: 7,
|
||||
location: 'Madrid, Spain',
|
||||
confidence_level: 0.85
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await this.forecastingService.createForecast(tenantId, forecastRequest);
|
||||
|
||||
log('blue', 'Expected Frontend Response Structure:');
|
||||
log('blue', '- Should have: forecast data with dates, values, confidence intervals');
|
||||
log('blue', 'Actual Backend Response:');
|
||||
log('blue', JSON.stringify(response, null, 2));
|
||||
|
||||
log('green', `✅ Forecast created successfully`);
|
||||
} catch (error) {
|
||||
if (error.message.includes('500') || error.message.includes('no models')) {
|
||||
log('yellow', `⚠️ Forecast failed as expected (training may not be complete): ${error.message}`);
|
||||
// Don't throw - this is expected if training hasn't completed
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Results Summary
|
||||
log('bright', '\\n📊 FRONTEND API SIMULATION TEST RESULTS');
|
||||
log('bright', '==========================================');
|
||||
log('green', `✅ Passed: ${this.testResults.passed}`);
|
||||
log('red', `❌ Failed: ${this.testResults.failed}`);
|
||||
|
||||
if (this.testResults.issues.length > 0) {
|
||||
log('red', '\\n🐛 Issues Found:');
|
||||
this.testResults.issues.forEach((issue, index) => {
|
||||
log('red', `${index + 1}. ${issue.test}: ${issue.error}`);
|
||||
});
|
||||
}
|
||||
|
||||
// API Alignment Analysis
|
||||
log('bright', '\\n🔍 API ALIGNMENT ANALYSIS');
|
||||
log('bright', '===========================');
|
||||
|
||||
log('blue', '🎯 Frontend-Backend Alignment Summary:');
|
||||
log('green', '✅ Auth Service: Registration and login endpoints align well');
|
||||
log('green', '✅ Tenant Service: Creation endpoint matches expected structure');
|
||||
log('yellow', '⚠️ Data Service: Import vs Validation endpoint confusion detected');
|
||||
log('green', '✅ Training Service: Job creation and status endpoints align');
|
||||
log('yellow', '⚠️ Forecasting Service: Endpoint structure may need verification');
|
||||
|
||||
log('blue', '\\n📋 Recommended Frontend API Improvements:');
|
||||
log('blue', '1. Add better error handling for different response formats');
|
||||
log('blue', '2. Consider adding response transformation layer');
|
||||
log('blue', '3. Add validation for expected response fields');
|
||||
log('blue', '4. Implement proper timeout handling for long operations');
|
||||
log('blue', '5. Add request/response logging for better debugging');
|
||||
|
||||
const successRate = (this.testResults.passed / (this.testResults.passed + this.testResults.failed)) * 100;
|
||||
log('bright', `\\n🎉 Overall Success Rate: ${successRate.toFixed(1)}%`);
|
||||
|
||||
if (successRate >= 80) {
|
||||
log('green', '✅ Frontend API abstraction is well-aligned with backend!');
|
||||
} else if (successRate >= 60) {
|
||||
log('yellow', '⚠️ Frontend API has some alignment issues that should be addressed');
|
||||
} else {
|
||||
log('red', '❌ Significant alignment issues detected - review required');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Run the test
|
||||
async function main() {
|
||||
const test = new FrontendApiSimulationTest();
|
||||
await test.runOnboardingFlowTest();
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
main().catch(console.error);
|
||||
}
|
||||
|
||||
module.exports = { FrontendApiSimulationTest };
|
||||
@@ -1,199 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Services Startup Test Script
|
||||
Tests that services actually start and respond to health checks
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import subprocess
|
||||
import time
|
||||
import requests
|
||||
from pathlib import Path
|
||||
|
||||
def run_command(cmd, cwd=None, timeout=300):
|
||||
"""Run a shell command with timeout"""
|
||||
try:
|
||||
print(f"Running: {cmd}")
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
shell=True,
|
||||
cwd=cwd,
|
||||
timeout=timeout,
|
||||
capture_output=True,
|
||||
text=True
|
||||
)
|
||||
return result
|
||||
except subprocess.TimeoutExpired:
|
||||
print(f"Command timed out after {timeout} seconds: {cmd}")
|
||||
return None
|
||||
except Exception as e:
|
||||
print(f"Error running command: {e}")
|
||||
return None
|
||||
|
||||
def wait_for_service(url, max_attempts=30, delay=10):
|
||||
"""Wait for a service to become healthy"""
|
||||
print(f"Waiting for service at {url}...")
|
||||
|
||||
for attempt in range(max_attempts):
|
||||
try:
|
||||
response = requests.get(url, timeout=5)
|
||||
if response.status_code == 200:
|
||||
print(f"✅ Service at {url} is healthy")
|
||||
return True
|
||||
except requests.exceptions.RequestException:
|
||||
pass
|
||||
|
||||
if attempt < max_attempts - 1:
|
||||
print(f"Attempt {attempt + 1}/{max_attempts} failed, waiting {delay}s...")
|
||||
time.sleep(delay)
|
||||
|
||||
print(f"❌ Service at {url} did not become healthy")
|
||||
return False
|
||||
|
||||
def test_essential_services():
|
||||
"""Test starting essential services and one application service"""
|
||||
print("🚀 Testing essential services startup...")
|
||||
|
||||
try:
|
||||
# Stop any running containers
|
||||
print("Stopping any existing containers...")
|
||||
run_command("docker compose down", timeout=120)
|
||||
|
||||
# Start infrastructure services first
|
||||
print("Starting infrastructure services...")
|
||||
infra_cmd = "docker compose up -d redis rabbitmq auth-db data-db"
|
||||
result = run_command(infra_cmd, timeout=300)
|
||||
|
||||
if result.returncode != 0:
|
||||
print("❌ Failed to start infrastructure services")
|
||||
if result.stderr:
|
||||
print(f"Error: {result.stderr}")
|
||||
return False
|
||||
|
||||
# Wait for infrastructure to be ready
|
||||
print("Waiting for infrastructure services...")
|
||||
time.sleep(30)
|
||||
|
||||
# Start one application service (auth-service) to test
|
||||
print("Starting auth service...")
|
||||
app_cmd = "docker compose up -d auth-service"
|
||||
result = run_command(app_cmd, timeout=300)
|
||||
|
||||
if result.returncode != 0:
|
||||
print("❌ Failed to start auth service")
|
||||
if result.stderr:
|
||||
print(f"Error: {result.stderr}")
|
||||
return False
|
||||
|
||||
# Wait for auth service to be ready
|
||||
print("Waiting for auth service to start...")
|
||||
time.sleep(45) # Give more time for app service
|
||||
|
||||
# Check if auth service is healthy
|
||||
auth_healthy = wait_for_service("http://localhost:8001/health", max_attempts=10, delay=5)
|
||||
|
||||
if not auth_healthy:
|
||||
# Show logs to debug
|
||||
print("Showing auth service logs...")
|
||||
logs_result = run_command("docker compose logs auth-service", timeout=30)
|
||||
if logs_result and logs_result.stdout:
|
||||
print("Auth service logs:")
|
||||
print(logs_result.stdout[-2000:]) # Last 2000 chars
|
||||
|
||||
return auth_healthy
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Error during services test: {e}")
|
||||
return False
|
||||
|
||||
def show_service_status():
|
||||
"""Show the status of all containers"""
|
||||
print("\n📊 Current container status:")
|
||||
result = run_command("docker compose ps", timeout=30)
|
||||
if result and result.stdout:
|
||||
print(result.stdout)
|
||||
else:
|
||||
print("Could not get container status")
|
||||
|
||||
def test_docker_compose_basic():
|
||||
"""Test basic docker-compose functionality"""
|
||||
print("🐳 Testing basic docker-compose functionality...")
|
||||
|
||||
try:
|
||||
# Test docker-compose up --dry-run if available
|
||||
result = run_command("docker compose config --services", timeout=30)
|
||||
|
||||
if result.returncode == 0:
|
||||
services = result.stdout.strip().split('\n')
|
||||
print(f"✅ Found {len(services)} services in docker-compose.yml:")
|
||||
for service in services:
|
||||
print(f" - {service}")
|
||||
return True
|
||||
else:
|
||||
print("❌ Could not list services from docker-compose.yml")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Error testing docker-compose: {e}")
|
||||
return False
|
||||
|
||||
def cleanup():
|
||||
"""Clean up all containers and resources"""
|
||||
print("\n🧹 Cleaning up...")
|
||||
|
||||
# Stop all containers
|
||||
run_command("docker compose down", timeout=180)
|
||||
|
||||
# Remove unused images (only test images)
|
||||
run_command("docker image prune -f", timeout=60)
|
||||
|
||||
print("✅ Cleanup completed")
|
||||
|
||||
def main():
|
||||
"""Main test function"""
|
||||
print("🧪 SERVICES STARTUP TEST")
|
||||
print("=" * 40)
|
||||
|
||||
base_path = Path(__file__).parent
|
||||
os.chdir(base_path)
|
||||
|
||||
success = True
|
||||
|
||||
try:
|
||||
# Test basic docker-compose functionality
|
||||
if not test_docker_compose_basic():
|
||||
success = False
|
||||
|
||||
# Test essential services startup
|
||||
if not test_essential_services():
|
||||
success = False
|
||||
|
||||
# Show final status
|
||||
show_service_status()
|
||||
|
||||
except KeyboardInterrupt:
|
||||
print("\n⚠️ Test interrupted by user")
|
||||
success = False
|
||||
except Exception as e:
|
||||
print(f"\n❌ Unexpected error: {e}")
|
||||
success = False
|
||||
finally:
|
||||
# Always cleanup
|
||||
cleanup()
|
||||
|
||||
# Final result
|
||||
print("\n" + "=" * 40)
|
||||
if success:
|
||||
print("🎉 SERVICES STARTUP TEST PASSED!")
|
||||
print("✅ Services can build and start successfully")
|
||||
print("💡 Your docker-compose setup is working correctly")
|
||||
return 0
|
||||
else:
|
||||
print("❌ SERVICES STARTUP TEST FAILED")
|
||||
print("⚠️ Some services may have issues starting")
|
||||
return 1
|
||||
|
||||
if __name__ == "__main__":
|
||||
exit_code = main()
|
||||
sys.exit(exit_code)
|
||||
@@ -1,149 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
echo "🧪 Testing Training Safeguards"
|
||||
echo "============================="
|
||||
|
||||
# Create user and tenant without importing data
|
||||
echo "1. Creating user and tenant..."
|
||||
|
||||
# Register user
|
||||
EMAIL="training.test.$(date +%s)@bakery.com"
|
||||
REGISTER_RESPONSE=$(curl -s -X POST "http://localhost:8000/api/v1/auth/register" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"email\": \"$EMAIL\", \"password\": \"TestPassword123!\", \"full_name\": \"Training Test User\", \"role\": \"admin\"}")
|
||||
|
||||
ACCESS_TOKEN=$(echo "$REGISTER_RESPONSE" | python3 -c "
|
||||
import json, sys
|
||||
try:
|
||||
data = json.load(sys.stdin)
|
||||
print(data.get('access_token', ''))
|
||||
except:
|
||||
pass
|
||||
" 2>/dev/null)
|
||||
|
||||
if [ -z "$ACCESS_TOKEN" ]; then
|
||||
echo "❌ User registration failed"
|
||||
echo "Response: $REGISTER_RESPONSE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✅ User registered successfully"
|
||||
|
||||
# Create tenant
|
||||
TENANT_RESPONSE=$(curl -s -X POST "http://localhost:8000/api/v1/tenants/register" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer $ACCESS_TOKEN" \
|
||||
-d '{"name": "Training Test Bakery", "business_type": "bakery", "address": "Test Address", "city": "Madrid", "postal_code": "28001", "phone": "+34600123456"}')
|
||||
|
||||
TENANT_ID=$(echo "$TENANT_RESPONSE" | python3 -c "
|
||||
import json, sys
|
||||
try:
|
||||
data = json.load(sys.stdin)
|
||||
print(data.get('id', ''))
|
||||
except:
|
||||
pass
|
||||
" 2>/dev/null)
|
||||
|
||||
if [ -z "$TENANT_ID" ]; then
|
||||
echo "❌ Tenant creation failed"
|
||||
echo "Response: $TENANT_RESPONSE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✅ Tenant created: $TENANT_ID"
|
||||
|
||||
# 2. Test training WITHOUT data (should fail gracefully)
|
||||
echo ""
|
||||
echo "2. Testing training WITHOUT sales data (should fail gracefully)..."
|
||||
TRAINING_RESPONSE=$(curl -s -X POST "http://localhost:8000/api/v1/tenants/$TENANT_ID/training/jobs" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer $ACCESS_TOKEN" \
|
||||
-d '{}')
|
||||
|
||||
echo "Training Response (no data):"
|
||||
echo "$TRAINING_RESPONSE" | python3 -m json.tool 2>/dev/null || echo "$TRAINING_RESPONSE"
|
||||
|
||||
# Check if the job was created but will fail
|
||||
JOB_ID=$(echo "$TRAINING_RESPONSE" | python3 -c "
|
||||
import json, sys
|
||||
try:
|
||||
data = json.load(sys.stdin)
|
||||
print(data.get('job_id', ''))
|
||||
except:
|
||||
pass
|
||||
" 2>/dev/null)
|
||||
|
||||
if [ -n "$JOB_ID" ]; then
|
||||
echo "✅ Training job created: $JOB_ID"
|
||||
echo "⏳ Waiting 10 seconds to see if safeguard triggers..."
|
||||
sleep 10
|
||||
|
||||
# Check training job status
|
||||
STATUS_RESPONSE=$(curl -s "http://localhost:8000/api/v1/tenants/$TENANT_ID/training/jobs/$JOB_ID/status" \
|
||||
-H "Authorization: Bearer $ACCESS_TOKEN")
|
||||
|
||||
echo "Job Status:"
|
||||
echo "$STATUS_RESPONSE" | python3 -m json.tool 2>/dev/null || echo "$STATUS_RESPONSE"
|
||||
else
|
||||
echo "ℹ️ No job ID returned - training may have been rejected immediately"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "3. Now importing some test data..."
|
||||
IMPORT_RESPONSE=$(curl -s -X POST "http://localhost:8000/api/v1/tenants/$TENANT_ID/sales/import" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer $ACCESS_TOKEN" \
|
||||
-d '{"data": "date,product,quantity,revenue\n2024-01-01,bread,10,20.0\n2024-01-02,croissant,5,15.0\n2024-01-03,pastry,8,24.0", "data_format": "csv", "filename": "test_data.csv"}')
|
||||
|
||||
echo "Import Response:"
|
||||
echo "$IMPORT_RESPONSE" | python3 -m json.tool 2>/dev/null || echo "$IMPORT_RESPONSE"
|
||||
|
||||
echo ""
|
||||
echo "4. Testing training WITH sales data..."
|
||||
TRAINING_WITH_DATA_RESPONSE=$(curl -s -X POST "http://localhost:8000/api/v1/tenants/$TENANT_ID/training/jobs" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer $ACCESS_TOKEN" \
|
||||
-d '{}')
|
||||
|
||||
echo "Training Response (with data):"
|
||||
echo "$TRAINING_WITH_DATA_RESPONSE" | python3 -m json.tool 2>/dev/null || echo "$TRAINING_WITH_DATA_RESPONSE"
|
||||
|
||||
JOB_ID_WITH_DATA=$(echo "$TRAINING_WITH_DATA_RESPONSE" | python3 -c "
|
||||
import json, sys
|
||||
try:
|
||||
data = json.load(sys.stdin)
|
||||
print(data.get('job_id', ''))
|
||||
except:
|
||||
pass
|
||||
" 2>/dev/null)
|
||||
|
||||
if [ -n "$JOB_ID_WITH_DATA" ]; then
|
||||
echo "✅ Training job with data created: $JOB_ID_WITH_DATA"
|
||||
echo "⏳ Monitoring progress for 30 seconds..."
|
||||
|
||||
for i in {1..6}; do
|
||||
sleep 5
|
||||
STATUS_RESPONSE=$(curl -s "http://localhost:8000/api/v1/tenants/$TENANT_ID/training/jobs/$JOB_ID_WITH_DATA/status" \
|
||||
-H "Authorization: Bearer $ACCESS_TOKEN")
|
||||
|
||||
STATUS=$(echo "$STATUS_RESPONSE" | python3 -c "
|
||||
import json, sys
|
||||
try:
|
||||
data = json.load(sys.stdin)
|
||||
print(data.get('status', 'unknown'))
|
||||
except:
|
||||
print('error')
|
||||
" 2>/dev/null)
|
||||
|
||||
echo "[$i/6] Status: $STATUS"
|
||||
|
||||
if [ "$STATUS" = "completed" ] || [ "$STATUS" = "failed" ]; then
|
||||
echo "Final Status Response:"
|
||||
echo "$STATUS_RESPONSE" | python3 -m json.tool 2>/dev/null || echo "$STATUS_RESPONSE"
|
||||
break
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "🏁 Training safeguard test completed!"
|
||||
@@ -1,171 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
echo "🧪 Testing Training with Actual Sales Data"
|
||||
echo "========================================"
|
||||
|
||||
# Create user and tenant
|
||||
echo "1. Creating user and tenant..."
|
||||
|
||||
EMAIL="training.withdata.$(date +%s)@bakery.com"
|
||||
REGISTER_RESPONSE=$(curl -s -X POST "http://localhost:8000/api/v1/auth/register" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"email\": \"$EMAIL\", \"password\": \"TestPassword123!\", \"full_name\": \"Training Test User\", \"role\": \"admin\"}")
|
||||
|
||||
ACCESS_TOKEN=$(echo "$REGISTER_RESPONSE" | python3 -c "
|
||||
import json, sys
|
||||
try:
|
||||
data = json.load(sys.stdin)
|
||||
print(data.get('access_token', ''))
|
||||
except:
|
||||
pass
|
||||
" 2>/dev/null)
|
||||
|
||||
if [ -z "$ACCESS_TOKEN" ]; then
|
||||
echo "❌ User registration failed"
|
||||
echo "Response: $REGISTER_RESPONSE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✅ User registered successfully"
|
||||
|
||||
# Create tenant
|
||||
TENANT_RESPONSE=$(curl -s -X POST "http://localhost:8000/api/v1/tenants/register" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer $ACCESS_TOKEN" \
|
||||
-d '{"name": "Training Test Bakery", "business_type": "bakery", "address": "Test Address", "city": "Madrid", "postal_code": "28001", "phone": "+34600123456"}')
|
||||
|
||||
TENANT_ID=$(echo "$TENANT_RESPONSE" | python3 -c "
|
||||
import json, sys
|
||||
try:
|
||||
data = json.load(sys.stdin)
|
||||
print(data.get('id', ''))
|
||||
except:
|
||||
pass
|
||||
" 2>/dev/null)
|
||||
|
||||
if [ -z "$TENANT_ID" ]; then
|
||||
echo "❌ Tenant creation failed"
|
||||
echo "Response: $TENANT_RESPONSE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✅ Tenant created: $TENANT_ID"
|
||||
|
||||
# 2. Import sales data using file upload
|
||||
echo ""
|
||||
echo "2. Importing sales data using file upload..."
|
||||
|
||||
IMPORT_RESPONSE=$(curl -s -w "\nHTTP_CODE:%{http_code}" -X POST "http://localhost:8000/api/v1/tenants/$TENANT_ID/sales/import" \
|
||||
-H "Authorization: Bearer $ACCESS_TOKEN" \
|
||||
-F "file=@test_sales_data.csv" \
|
||||
-F "file_format=csv")
|
||||
|
||||
HTTP_CODE=$(echo "$IMPORT_RESPONSE" | grep "HTTP_CODE:" | cut -d: -f2)
|
||||
IMPORT_RESPONSE=$(echo "$IMPORT_RESPONSE" | sed '/HTTP_CODE:/d')
|
||||
|
||||
echo "Import HTTP Status Code: $HTTP_CODE"
|
||||
echo "Import Response:"
|
||||
echo "$IMPORT_RESPONSE" | python3 -m json.tool 2>/dev/null || echo "$IMPORT_RESPONSE"
|
||||
|
||||
if [ "$HTTP_CODE" = "200" ]; then
|
||||
echo "✅ Data import successful!"
|
||||
else
|
||||
echo "❌ Data import failed"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 3. Test training with data
|
||||
echo ""
|
||||
echo "3. Testing training WITH imported sales data..."
|
||||
TRAINING_RESPONSE=$(curl -s -X POST "http://localhost:8000/api/v1/tenants/$TENANT_ID/training/jobs" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer $ACCESS_TOKEN" \
|
||||
-d '{}')
|
||||
|
||||
echo "Training Response:"
|
||||
echo "$TRAINING_RESPONSE" | python3 -m json.tool 2>/dev/null || echo "$TRAINING_RESPONSE"
|
||||
|
||||
JOB_ID=$(echo "$TRAINING_RESPONSE" | python3 -c "
|
||||
import json, sys
|
||||
try:
|
||||
data = json.load(sys.stdin)
|
||||
print(data.get('job_id', ''))
|
||||
except:
|
||||
pass
|
||||
" 2>/dev/null)
|
||||
|
||||
if [ -n "$JOB_ID" ]; then
|
||||
echo "✅ Training job created with data: $JOB_ID"
|
||||
echo "⏳ Monitoring progress for 60 seconds..."
|
||||
|
||||
for i in {1..12}; do
|
||||
sleep 5
|
||||
STATUS_RESPONSE=$(curl -s "http://localhost:8000/api/v1/tenants/$TENANT_ID/training/jobs/$JOB_ID/status" \
|
||||
-H "Authorization: Bearer $ACCESS_TOKEN" 2>/dev/null)
|
||||
|
||||
STATUS=$(echo "$STATUS_RESPONSE" | python3 -c "
|
||||
import json, sys
|
||||
try:
|
||||
data = json.load(sys.stdin)
|
||||
print(data.get('status', 'unknown'))
|
||||
except:
|
||||
print('error')
|
||||
" 2>/dev/null)
|
||||
|
||||
PROGRESS=$(echo "$STATUS_RESPONSE" | python3 -c "
|
||||
import json, sys
|
||||
try:
|
||||
data = json.load(sys.stdin)
|
||||
print(data.get('progress', 0))
|
||||
except:
|
||||
print(0)
|
||||
" 2>/dev/null)
|
||||
|
||||
CURRENT_STEP=$(echo "$STATUS_RESPONSE" | python3 -c "
|
||||
import json, sys
|
||||
try:
|
||||
data = json.load(sys.stdin)
|
||||
print(data.get('current_step', 'unknown'))
|
||||
except:
|
||||
print('unknown')
|
||||
" 2>/dev/null)
|
||||
|
||||
echo "[$i/12] Status: $STATUS | Progress: $PROGRESS% | Step: $CURRENT_STEP"
|
||||
|
||||
if [ "$STATUS" = "completed" ] || [ "$STATUS" = "failed" ]; then
|
||||
echo ""
|
||||
echo "Final Status Response:"
|
||||
echo "$STATUS_RESPONSE" | python3 -m json.tool 2>/dev/null || echo "$STATUS_RESPONSE"
|
||||
|
||||
if [ "$STATUS" = "completed" ]; then
|
||||
echo "✅ Training completed successfully!"
|
||||
|
||||
# Test forecast endpoint
|
||||
echo ""
|
||||
echo "4. Testing forecast endpoint..."
|
||||
FORECAST_RESPONSE=$(curl -s -X POST "http://localhost:8000/api/v1/tenants/$TENANT_ID/forecasts" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer $ACCESS_TOKEN" \
|
||||
-d '{"product_name": "bread", "days_ahead": 7}')
|
||||
|
||||
echo "Forecast Response:"
|
||||
echo "$FORECAST_RESPONSE" | python3 -m json.tool 2>/dev/null || echo "$FORECAST_RESPONSE"
|
||||
|
||||
elif [ "$STATUS" = "failed" ]; then
|
||||
echo "❌ Training failed"
|
||||
fi
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$i" -eq 12 ]; then
|
||||
echo "⏰ Training still in progress after 60 seconds"
|
||||
echo "Final status check:"
|
||||
echo "$STATUS_RESPONSE" | python3 -m json.tool 2>/dev/null || echo "$STATUS_RESPONSE"
|
||||
fi
|
||||
else
|
||||
echo "❌ No training job ID returned"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "🏁 Training with data test completed!"
|
||||
@@ -1,436 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# =================================================================
|
||||
# COMPREHENSIVE ADMIN ROLE DEBUG SCRIPT
|
||||
# =================================================================
|
||||
# This script will trace the entire flow of admin role assignment
|
||||
# from registration through JWT token creation to API calls
|
||||
|
||||
# Configuration
|
||||
API_BASE="http://localhost:8000"
|
||||
AUTH_BASE="http://localhost:8001"
|
||||
TEST_EMAIL="debug.admin.test.$(date +%s)@bakery.com"
|
||||
TEST_PASSWORD="DebugPassword123!"
|
||||
TEST_NAME="Debug Admin User"
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
PURPLE='\033[0;35m'
|
||||
CYAN='\033[0;36m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
echo -e "${CYAN}🔍 COMPREHENSIVE ADMIN ROLE DEBUG${NC}"
|
||||
echo -e "${CYAN}=================================${NC}"
|
||||
echo "Test Email: $TEST_EMAIL"
|
||||
echo "API Base: $API_BASE"
|
||||
echo "Auth Base: $AUTH_BASE"
|
||||
echo ""
|
||||
|
||||
# Utility functions
|
||||
log_step() {
|
||||
echo -e "${BLUE}📋 $1${NC}"
|
||||
}
|
||||
|
||||
log_success() {
|
||||
echo -e "${GREEN}✅ $1${NC}"
|
||||
}
|
||||
|
||||
log_error() {
|
||||
echo -e "${RED}❌ $1${NC}"
|
||||
}
|
||||
|
||||
log_warning() {
|
||||
echo -e "${YELLOW}⚠️ $1${NC}"
|
||||
}
|
||||
|
||||
log_debug() {
|
||||
echo -e "${PURPLE}🐛 DEBUG: $1${NC}"
|
||||
}
|
||||
|
||||
# Function to decode JWT token (requires jq and base64)
|
||||
decode_jwt() {
|
||||
local token="$1"
|
||||
local part="$2" # header=0, payload=1, signature=2
|
||||
|
||||
if [ -z "$token" ]; then
|
||||
echo "No token provided"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Split token by dots
|
||||
IFS='.' read -ra PARTS <<< "$token"
|
||||
|
||||
if [ ${#PARTS[@]} -ne 3 ]; then
|
||||
echo "Invalid JWT format"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Decode the specified part (default to payload)
|
||||
local part_index=${part:-1}
|
||||
local encoded_part="${PARTS[$part_index]}"
|
||||
|
||||
# Add padding if needed
|
||||
local padding=$(( 4 - ${#encoded_part} % 4 ))
|
||||
if [ $padding -ne 4 ]; then
|
||||
encoded_part="${encoded_part}$(printf '%*s' $padding | tr ' ' '=')"
|
||||
fi
|
||||
|
||||
# Decode and format as JSON
|
||||
echo "$encoded_part" | base64 -d 2>/dev/null | jq '.' 2>/dev/null || echo "Failed to decode JWT part"
|
||||
}
|
||||
|
||||
# Function to extract JSON field
|
||||
extract_json_field() {
|
||||
local json="$1"
|
||||
local field="$2"
|
||||
echo "$json" | python3 -c "
|
||||
import json, sys
|
||||
try:
|
||||
data = json.load(sys.stdin)
|
||||
print(data.get('$field', ''))
|
||||
except:
|
||||
print('')
|
||||
" 2>/dev/null
|
||||
}
|
||||
|
||||
# =================================================================
|
||||
# STEP 1: VERIFY SERVICES ARE RUNNING
|
||||
# =================================================================
|
||||
|
||||
log_step "Step 1: Verifying services are running"
|
||||
|
||||
# Check API Gateway
|
||||
if ! curl -s "$API_BASE/health" > /dev/null; then
|
||||
log_error "API Gateway is not responding at $API_BASE"
|
||||
echo "Please ensure services are running: docker-compose up -d"
|
||||
exit 1
|
||||
fi
|
||||
log_success "API Gateway is responding"
|
||||
|
||||
# Check Auth Service directly
|
||||
if ! curl -s "$AUTH_BASE/health" > /dev/null; then
|
||||
log_error "Auth Service is not responding at $AUTH_BASE"
|
||||
exit 1
|
||||
fi
|
||||
log_success "Auth Service is responding"
|
||||
|
||||
echo ""
|
||||
|
||||
# =================================================================
|
||||
# STEP 2: USER REGISTRATION WITH DETAILED DEBUGGING
|
||||
# =================================================================
|
||||
|
||||
log_step "Step 2: User Registration with Admin Role"
|
||||
echo "Email: $TEST_EMAIL"
|
||||
echo "Role: admin (explicitly set)"
|
||||
echo ""
|
||||
|
||||
log_debug "Sending registration request..."
|
||||
|
||||
# Registration request with explicit role
|
||||
REGISTER_RESPONSE=$(curl -s -w "\nHTTP_CODE:%{http_code}" -X POST "$API_BASE/api/v1/auth/register" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{
|
||||
\"email\": \"$TEST_EMAIL\",
|
||||
\"password\": \"$TEST_PASSWORD\",
|
||||
\"full_name\": \"$TEST_NAME\",
|
||||
\"role\": \"admin\"
|
||||
}")
|
||||
|
||||
# Extract HTTP code and response
|
||||
HTTP_CODE=$(echo "$REGISTER_RESPONSE" | grep "HTTP_CODE:" | cut -d: -f2)
|
||||
REGISTER_RESPONSE=$(echo "$REGISTER_RESPONSE" | sed '/HTTP_CODE:/d')
|
||||
|
||||
echo "Registration HTTP Status: $HTTP_CODE"
|
||||
echo "Registration Response:"
|
||||
echo "$REGISTER_RESPONSE" | python3 -m json.tool 2>/dev/null || echo "$REGISTER_RESPONSE"
|
||||
|
||||
if [ "$HTTP_CODE" != "200" ] && [ "$HTTP_CODE" != "201" ]; then
|
||||
log_error "Registration failed with HTTP $HTTP_CODE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Extract user information from registration response
|
||||
USER_ID=$(extract_json_field "$REGISTER_RESPONSE" "user_id")
|
||||
if [ -z "$USER_ID" ]; then
|
||||
# Try alternative field names
|
||||
USER_ID=$(echo "$REGISTER_RESPONSE" | python3 -c "
|
||||
import json, sys
|
||||
try:
|
||||
data = json.load(sys.stdin)
|
||||
user = data.get('user', {})
|
||||
print(user.get('id', data.get('id', '')))
|
||||
except:
|
||||
print('')
|
||||
" 2>/dev/null)
|
||||
fi
|
||||
|
||||
ACCESS_TOKEN=$(extract_json_field "$REGISTER_RESPONSE" "access_token")
|
||||
REFRESH_TOKEN=$(extract_json_field "$REGISTER_RESPONSE" "refresh_token")
|
||||
|
||||
log_debug "Extracted from registration:"
|
||||
echo " User ID: $USER_ID"
|
||||
echo " Access Token: ${ACCESS_TOKEN:0:50}..."
|
||||
echo " Refresh Token: ${REFRESH_TOKEN:0:50}..."
|
||||
|
||||
echo ""
|
||||
|
||||
# =================================================================
|
||||
# STEP 3: DECODE AND ANALYZE JWT TOKEN
|
||||
# =================================================================
|
||||
|
||||
log_step "Step 3: JWT Token Analysis"
|
||||
|
||||
if [ -n "$ACCESS_TOKEN" ]; then
|
||||
log_debug "Decoding JWT Header:"
|
||||
JWT_HEADER=$(decode_jwt "$ACCESS_TOKEN" 0)
|
||||
echo "$JWT_HEADER"
|
||||
|
||||
echo ""
|
||||
log_debug "Decoding JWT Payload:"
|
||||
JWT_PAYLOAD=$(decode_jwt "$ACCESS_TOKEN" 1)
|
||||
echo "$JWT_PAYLOAD"
|
||||
|
||||
# Extract role from JWT payload
|
||||
JWT_ROLE=$(echo "$JWT_PAYLOAD" | jq -r '.role // "NOT_FOUND"' 2>/dev/null)
|
||||
JWT_USER_ID=$(echo "$JWT_PAYLOAD" | jq -r '.user_id // "NOT_FOUND"' 2>/dev/null)
|
||||
JWT_EMAIL=$(echo "$JWT_PAYLOAD" | jq -r '.email // "NOT_FOUND"' 2>/dev/null)
|
||||
|
||||
echo ""
|
||||
log_debug "JWT Payload Analysis:"
|
||||
echo " Role in JWT: $JWT_ROLE"
|
||||
echo " User ID in JWT: $JWT_USER_ID"
|
||||
echo " Email in JWT: $JWT_EMAIL"
|
||||
|
||||
if [ "$JWT_ROLE" = "admin" ]; then
|
||||
log_success "JWT contains admin role correctly"
|
||||
else
|
||||
log_error "JWT role is '$JWT_ROLE', expected 'admin'"
|
||||
fi
|
||||
else
|
||||
log_error "No access token received in registration response"
|
||||
echo "Cannot analyze JWT"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
|
||||
# =================================================================
|
||||
# STEP 4: VERIFY USER PROFILE ENDPOINT
|
||||
# =================================================================
|
||||
|
||||
log_step "Step 4: User Profile Verification"
|
||||
|
||||
if [ -n "$ACCESS_TOKEN" ]; then
|
||||
log_debug "Calling /users/me endpoint..."
|
||||
|
||||
USER_PROFILE_RESPONSE=$(curl -s -w "\nHTTP_CODE:%{http_code}" -X GET "$API_BASE/api/v1/users/me" \
|
||||
-H "Authorization: Bearer $ACCESS_TOKEN")
|
||||
|
||||
# Extract HTTP code and response
|
||||
HTTP_CODE=$(echo "$USER_PROFILE_RESPONSE" | grep "HTTP_CODE:" | cut -d: -f2)
|
||||
USER_PROFILE_RESPONSE=$(echo "$USER_PROFILE_RESPONSE" | sed '/HTTP_CODE:/d')
|
||||
|
||||
echo "User Profile HTTP Status: $HTTP_CODE"
|
||||
echo "User Profile Response:"
|
||||
echo "$USER_PROFILE_RESPONSE" | python3 -m json.tool 2>/dev/null || echo "$USER_PROFILE_RESPONSE"
|
||||
|
||||
if [ "$HTTP_CODE" = "200" ]; then
|
||||
PROFILE_ROLE=$(extract_json_field "$USER_PROFILE_RESPONSE" "role")
|
||||
PROFILE_EMAIL=$(extract_json_field "$USER_PROFILE_RESPONSE" "email")
|
||||
PROFILE_USER_ID=$(extract_json_field "$USER_PROFILE_RESPONSE" "id")
|
||||
|
||||
echo ""
|
||||
log_debug "Profile endpoint analysis:"
|
||||
echo " Role from profile: $PROFILE_ROLE"
|
||||
echo " Email from profile: $PROFILE_EMAIL"
|
||||
echo " User ID from profile: $PROFILE_USER_ID"
|
||||
|
||||
if [ "$PROFILE_ROLE" = "admin" ]; then
|
||||
log_success "Profile endpoint shows admin role correctly"
|
||||
else
|
||||
log_error "Profile endpoint shows role '$PROFILE_ROLE', expected 'admin'"
|
||||
fi
|
||||
else
|
||||
log_error "Failed to get user profile (HTTP $HTTP_CODE)"
|
||||
fi
|
||||
else
|
||||
log_error "No access token available for profile verification"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
|
||||
# =================================================================
|
||||
# STEP 5: DIRECT DATABASE VERIFICATION (if possible)
|
||||
# =================================================================
|
||||
|
||||
log_step "Step 5: Database Verification"
|
||||
|
||||
log_debug "Attempting to verify user role in database..."
|
||||
|
||||
# Try to connect to the auth database directly
|
||||
DB_QUERY_RESULT=$(docker exec bakery-auth-db psql -U auth_user -d auth_db -t -c "SELECT id, email, role, created_at FROM users WHERE email='$TEST_EMAIL';" 2>/dev/null || echo "DB_ACCESS_FAILED")
|
||||
|
||||
if [ "$DB_QUERY_RESULT" != "DB_ACCESS_FAILED" ]; then
|
||||
echo "Database Query Result:"
|
||||
echo "$DB_QUERY_RESULT"
|
||||
|
||||
# Parse the database result
|
||||
DB_ROLE=$(echo "$DB_QUERY_RESULT" | awk -F'|' '{print $3}' | tr -d ' ')
|
||||
|
||||
log_debug "Role in database: '$DB_ROLE'"
|
||||
|
||||
if [ "$DB_ROLE" = "admin" ]; then
|
||||
log_success "Database shows admin role correctly"
|
||||
else
|
||||
log_error "Database shows role '$DB_ROLE', expected 'admin'"
|
||||
fi
|
||||
else
|
||||
log_warning "Cannot access database directly (this is normal in some setups)"
|
||||
echo "You can manually check with:"
|
||||
echo " docker exec bakery-auth-db psql -U auth_user -d auth_db -c \"SELECT id, email, role FROM users WHERE email='$TEST_EMAIL';\""
|
||||
fi
|
||||
|
||||
echo ""
|
||||
|
||||
# =================================================================
|
||||
# STEP 6: TEST ADMIN ENDPOINT ACCESS
|
||||
# =================================================================
|
||||
|
||||
log_step "Step 6: Testing Admin Endpoint Access"
|
||||
|
||||
if [ -n "$ACCESS_TOKEN" ] && [ -n "$USER_ID" ]; then
|
||||
log_debug "Attempting to call admin deletion preview endpoint..."
|
||||
|
||||
ADMIN_TEST_RESPONSE=$(curl -s -w "\nHTTP_CODE:%{http_code}" -X GET "$API_BASE/api/v1/users/delete/$USER_ID/deletion-preview" \
|
||||
-H "Authorization: Bearer $ACCESS_TOKEN")
|
||||
|
||||
# Extract HTTP code and response
|
||||
HTTP_CODE=$(echo "$ADMIN_TEST_RESPONSE" | grep "HTTP_CODE:" | cut -d: -f2)
|
||||
ADMIN_TEST_RESPONSE=$(echo "$ADMIN_TEST_RESPONSE" | sed '/HTTP_CODE:/d')
|
||||
|
||||
echo "Admin Endpoint HTTP Status: $HTTP_CODE"
|
||||
echo "Admin Endpoint Response:"
|
||||
echo "$ADMIN_TEST_RESPONSE" | python3 -m json.tool 2>/dev/null || echo "$ADMIN_TEST_RESPONSE"
|
||||
|
||||
case "$HTTP_CODE" in
|
||||
"200")
|
||||
log_success "Admin endpoint access successful!"
|
||||
;;
|
||||
"403")
|
||||
log_error "Access denied - user does not have admin privileges"
|
||||
echo "This confirms the role issue!"
|
||||
;;
|
||||
"401")
|
||||
log_error "Authentication failed - token may be invalid"
|
||||
;;
|
||||
*)
|
||||
log_warning "Unexpected response from admin endpoint (HTTP $HTTP_CODE)"
|
||||
;;
|
||||
esac
|
||||
else
|
||||
log_error "Cannot test admin endpoint - missing access token or user ID"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
|
||||
# =================================================================
|
||||
# STEP 7: AUTH SERVICE LOGS ANALYSIS
|
||||
# =================================================================
|
||||
|
||||
log_step "Step 7: Auth Service Logs Analysis"
|
||||
|
||||
log_debug "Checking recent auth service logs..."
|
||||
|
||||
AUTH_LOGS=$(docker logs --tail 50 bakery-auth-service 2>/dev/null | grep -E "(register|role|admin|$TEST_EMAIL)" || echo "NO_LOGS_FOUND")
|
||||
|
||||
if [ "$AUTH_LOGS" != "NO_LOGS_FOUND" ]; then
|
||||
echo "Recent Auth Service Logs (filtered):"
|
||||
echo "$AUTH_LOGS"
|
||||
else
|
||||
log_warning "Cannot access auth service logs"
|
||||
echo "You can check manually with:"
|
||||
echo " docker logs bakery-auth-service | grep -E \"(register|role|admin)\""
|
||||
fi
|
||||
|
||||
echo ""
|
||||
|
||||
# =================================================================
|
||||
# STEP 8: SUMMARY AND RECOMMENDATIONS
|
||||
# =================================================================
|
||||
|
||||
log_step "Step 8: Debug Summary and Recommendations"
|
||||
|
||||
echo -e "${CYAN}🔍 DEBUG SUMMARY${NC}"
|
||||
echo -e "${CYAN}===============${NC}"
|
||||
|
||||
echo ""
|
||||
echo "Test User Details:"
|
||||
echo " Email: $TEST_EMAIL"
|
||||
echo " Expected Role: admin"
|
||||
echo " User ID: ${USER_ID:-'NOT_EXTRACTED'}"
|
||||
|
||||
echo ""
|
||||
echo "Token Analysis:"
|
||||
echo " Access Token Received: $([ -n "$ACCESS_TOKEN" ] && echo 'YES' || echo 'NO')"
|
||||
echo " Role in JWT: ${JWT_ROLE:-'NOT_FOUND'}"
|
||||
echo " JWT User ID: ${JWT_USER_ID:-'NOT_FOUND'}"
|
||||
|
||||
echo ""
|
||||
echo "API Responses:"
|
||||
echo " Registration HTTP: ${HTTP_CODE:-'UNKNOWN'}"
|
||||
echo " Profile Role: ${PROFILE_ROLE:-'NOT_CHECKED'}"
|
||||
echo " Admin Endpoint Access: $([ -n "$ADMIN_TEST_RESPONSE" ] && echo "TESTED" || echo "NOT_TESTED")"
|
||||
|
||||
echo ""
|
||||
echo "Database Verification:"
|
||||
echo " DB Role: ${DB_ROLE:-'NOT_CHECKED'}"
|
||||
|
||||
echo ""
|
||||
log_debug "Potential Issues:"
|
||||
|
||||
# Issue 1: Role not in JWT
|
||||
if [ "$JWT_ROLE" != "admin" ]; then
|
||||
echo " ❌ Role not correctly encoded in JWT token"
|
||||
echo " → Check auth service JWT creation logic"
|
||||
echo " → Verify SecurityManager.create_access_token includes role"
|
||||
fi
|
||||
|
||||
# Issue 2: Registration not setting role
|
||||
if [ "$PROFILE_ROLE" != "admin" ]; then
|
||||
echo " ❌ User profile doesn't show admin role"
|
||||
echo " → Check user registration saves role correctly"
|
||||
echo " → Verify database schema allows role field"
|
||||
fi
|
||||
|
||||
# Issue 3: Admin endpoint still denies access
|
||||
if [ "$HTTP_CODE" = "403" ]; then
|
||||
echo " ❌ Admin endpoint denies access despite admin role"
|
||||
echo " → Check require_admin_role_dep implementation"
|
||||
echo " → Verify role extraction from token in auth decorators"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo -e "${YELLOW}🔧 RECOMMENDED NEXT STEPS:${NC}"
|
||||
echo "1. Check the auth service logs during registration"
|
||||
echo "2. Verify the database schema has a 'role' column"
|
||||
echo "3. Check if the JWT creation includes the role field"
|
||||
echo "4. Verify the role extraction in get_current_user_dep"
|
||||
echo "5. Test with a direct database role update if needed"
|
||||
|
||||
echo ""
|
||||
echo -e "${CYAN}🧪 Manual Verification Commands:${NC}"
|
||||
echo "# Check database directly:"
|
||||
echo "docker exec bakery-auth-db psql -U auth_user -d auth_db -c \"SELECT id, email, role FROM users WHERE email='$TEST_EMAIL';\""
|
||||
echo ""
|
||||
echo "# Check auth service logs:"
|
||||
echo "docker logs bakery-auth-service | grep -A5 -B5 '$TEST_EMAIL'"
|
||||
echo ""
|
||||
echo "# Decode JWT manually:"
|
||||
echo "echo '$ACCESS_TOKEN' | cut -d. -f2 | base64 -d | jq ."
|
||||
|
||||
echo ""
|
||||
log_success "Debug script completed!"
|
||||
echo -e "${YELLOW}Review the analysis above to identify the root cause.${NC}"
|
||||
@@ -1,361 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# =================================================================
|
||||
# ONBOARDING FLOW DEBUG SCRIPT
|
||||
# =================================================================
|
||||
# This script replicates the exact onboarding flow to debug the 403 issue
|
||||
|
||||
# Configuration
|
||||
API_BASE="http://localhost:8000"
|
||||
TEST_EMAIL="onboarding.debug.$(date +%s)@bakery.com"
|
||||
TEST_PASSWORD="OnboardingDebug123!"
|
||||
TEST_NAME="Onboarding Debug User"
|
||||
|
||||
# Colors
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
PURPLE='\033[0;35m'
|
||||
CYAN='\033[0;36m'
|
||||
NC='\033[0m'
|
||||
|
||||
echo -e "${CYAN}🔍 ONBOARDING FLOW DEBUG - EXACT REPLICATION${NC}"
|
||||
echo -e "${CYAN}=============================================${NC}"
|
||||
echo "Test Email: $TEST_EMAIL"
|
||||
echo "API Base: $API_BASE"
|
||||
echo ""
|
||||
|
||||
# Utility functions
|
||||
log_step() {
|
||||
echo -e "${BLUE}📋 $1${NC}"
|
||||
}
|
||||
|
||||
log_success() {
|
||||
echo -e "${GREEN}✅ $1${NC}"
|
||||
}
|
||||
|
||||
log_error() {
|
||||
echo -e "${RED}❌ $1${NC}"
|
||||
}
|
||||
|
||||
log_debug() {
|
||||
echo -e "${PURPLE}🐛 DEBUG: $1${NC}"
|
||||
}
|
||||
|
||||
extract_json_field() {
|
||||
local json="$1"
|
||||
local field="$2"
|
||||
echo "$json" | python3 -c "
|
||||
import json, sys
|
||||
try:
|
||||
data = json.load(sys.stdin)
|
||||
user = data.get('user', {})
|
||||
print(user.get('$field', data.get('$field', '')))
|
||||
except:
|
||||
print('')
|
||||
" 2>/dev/null
|
||||
}
|
||||
|
||||
# =================================================================
|
||||
# STEP 1: REGISTER USER (EXACTLY LIKE ONBOARDING SCRIPT)
|
||||
# =================================================================
|
||||
|
||||
log_step "Step 1: User Registration (Onboarding Style)"
|
||||
echo "Email: $TEST_EMAIL"
|
||||
echo "Role: admin (explicitly set)"
|
||||
echo ""
|
||||
|
||||
log_debug "Registering via API Gateway (same as onboarding script)..."
|
||||
|
||||
REGISTER_RESPONSE=$(curl -s -X POST "$API_BASE/api/v1/auth/register" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{
|
||||
\"email\": \"$TEST_EMAIL\",
|
||||
\"password\": \"$TEST_PASSWORD\",
|
||||
\"full_name\": \"$TEST_NAME\",
|
||||
\"role\": \"admin\"
|
||||
}")
|
||||
|
||||
echo "Registration Response:"
|
||||
echo "$REGISTER_RESPONSE" | python3 -m json.tool 2>/dev/null || echo "$REGISTER_RESPONSE"
|
||||
|
||||
# Extract user ID exactly like onboarding script
|
||||
USER_ID=$(echo "$REGISTER_RESPONSE" | python3 -c "
|
||||
import json, sys
|
||||
try:
|
||||
data = json.load(sys.stdin)
|
||||
user = data.get('user', {})
|
||||
print(user.get('id', ''))
|
||||
except:
|
||||
print('')
|
||||
")
|
||||
|
||||
if [ -n "$USER_ID" ]; then
|
||||
log_success "User ID extracted: $USER_ID"
|
||||
else
|
||||
log_error "Failed to extract user ID"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo ""
|
||||
|
||||
# =================================================================
|
||||
# STEP 2: LOGIN (EXACTLY LIKE ONBOARDING SCRIPT)
|
||||
# =================================================================
|
||||
|
||||
log_step "Step 2: User Login (Onboarding Style)"
|
||||
|
||||
LOGIN_RESPONSE=$(curl -s -X POST "$API_BASE/api/v1/auth/login" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{
|
||||
\"email\": \"$TEST_EMAIL\",
|
||||
\"password\": \"$TEST_PASSWORD\"
|
||||
}")
|
||||
|
||||
echo "Login Response:"
|
||||
echo "$LOGIN_RESPONSE" | python3 -m json.tool 2>/dev/null || echo "$LOGIN_RESPONSE"
|
||||
|
||||
ACCESS_TOKEN=$(extract_json_field "$LOGIN_RESPONSE" "access_token")
|
||||
|
||||
if [ -n "$ACCESS_TOKEN" ]; then
|
||||
log_success "Access token obtained: ${ACCESS_TOKEN:0:50}..."
|
||||
else
|
||||
log_error "Failed to extract access token"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo ""
|
||||
|
||||
# =================================================================
|
||||
# STEP 3: CHECK PROFILE ENDPOINT
|
||||
# =================================================================
|
||||
|
||||
log_step "Step 3: Check Profile Endpoint"
|
||||
|
||||
PROFILE_RESPONSE=$(curl -s -w "\nHTTP_CODE:%{http_code}" -X GET "$API_BASE/api/v1/users/me" \
|
||||
-H "Authorization: Bearer $ACCESS_TOKEN")
|
||||
|
||||
HTTP_CODE=$(echo "$PROFILE_RESPONSE" | grep "HTTP_CODE:" | cut -d: -f2)
|
||||
PROFILE_RESPONSE=$(echo "$PROFILE_RESPONSE" | sed '/HTTP_CODE:/d')
|
||||
|
||||
echo "Profile HTTP Status: $HTTP_CODE"
|
||||
echo "Profile Response:"
|
||||
echo "$PROFILE_RESPONSE" | python3 -m json.tool 2>/dev/null || echo "$PROFILE_RESPONSE"
|
||||
|
||||
PROFILE_ROLE=$(extract_json_field "$PROFILE_RESPONSE" "role")
|
||||
echo ""
|
||||
log_debug "Profile role: $PROFILE_ROLE"
|
||||
|
||||
if [ "$PROFILE_ROLE" = "admin" ]; then
|
||||
log_success "Profile shows admin role correctly"
|
||||
else
|
||||
log_error "Profile shows role '$PROFILE_ROLE', expected 'admin'"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
|
||||
# =================================================================
|
||||
# STEP 4: TEST ADMIN DELETION PREVIEW (EXACTLY LIKE ONBOARDING)
|
||||
# =================================================================
|
||||
|
||||
log_step "Step 4: Admin Deletion Preview (Onboarding Style)"
|
||||
|
||||
log_debug "Calling deletion preview endpoint..."
|
||||
|
||||
DELETION_PREVIEW_RESPONSE=$(curl -s -w "\nHTTP_CODE:%{http_code}" -X GET "$API_BASE/api/v1/users/delete/$USER_ID/deletion-preview" \
|
||||
-H "Authorization: Bearer $ACCESS_TOKEN" \
|
||||
-H "Content-Type: application/json")
|
||||
|
||||
HTTP_CODE=$(echo "$DELETION_PREVIEW_RESPONSE" | grep "HTTP_CODE:" | cut -d: -f2)
|
||||
DELETION_PREVIEW_RESPONSE=$(echo "$DELETION_PREVIEW_RESPONSE" | sed '/HTTP_CODE:/d')
|
||||
|
||||
echo "Deletion Preview HTTP Status: $HTTP_CODE"
|
||||
echo "Deletion Preview Response:"
|
||||
echo "$DELETION_PREVIEW_RESPONSE" | python3 -m json.tool 2>/dev/null || echo "$DELETION_PREVIEW_RESPONSE"
|
||||
|
||||
case "$HTTP_CODE" in
|
||||
"200")
|
||||
log_success "Deletion preview successful!"
|
||||
;;
|
||||
"403")
|
||||
log_error "403 Forbidden - Admin access denied!"
|
||||
echo "This is the same error as in onboarding script"
|
||||
;;
|
||||
"401")
|
||||
log_error "401 Unauthorized - Token issue"
|
||||
;;
|
||||
*)
|
||||
log_error "Unexpected HTTP status: $HTTP_CODE"
|
||||
;;
|
||||
esac
|
||||
|
||||
echo ""
|
||||
|
||||
# =================================================================
|
||||
# STEP 5: TEST ACTUAL DELETION (EXACTLY LIKE ONBOARDING)
|
||||
# =================================================================
|
||||
|
||||
log_step "Step 5: Admin User Deletion (Onboarding Style)"
|
||||
|
||||
if [ "$HTTP_CODE" = "200" ]; then
|
||||
log_debug "Attempting actual deletion..."
|
||||
|
||||
DELETION_RESPONSE=$(curl -s -w "\nHTTP_CODE:%{http_code}" -X DELETE "$API_BASE/api/v1/users/delete/$USER_ID" \
|
||||
-H "Authorization: Bearer $ACCESS_TOKEN")
|
||||
|
||||
HTTP_CODE=$(echo "$DELETION_RESPONSE" | grep "HTTP_CODE:" | cut -d: -f2)
|
||||
DELETION_RESPONSE=$(echo "$DELETION_RESPONSE" | sed '/HTTP_CODE:/d')
|
||||
|
||||
echo "Deletion HTTP Status: $HTTP_CODE"
|
||||
echo "Deletion Response:"
|
||||
echo "$DELETION_RESPONSE" | python3 -m json.tool 2>/dev/null || echo "$DELETION_RESPONSE"
|
||||
|
||||
case "$HTTP_CODE" in
|
||||
"200")
|
||||
log_success "Admin deletion successful!"
|
||||
;;
|
||||
"403")
|
||||
log_error "403 Forbidden - Admin deletion denied!"
|
||||
;;
|
||||
*)
|
||||
log_error "Deletion failed with HTTP $HTTP_CODE"
|
||||
;;
|
||||
esac
|
||||
else
|
||||
log_error "Skipping deletion test due to preview failure"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
|
||||
# =================================================================
|
||||
# STEP 6: DETAILED TOKEN ANALYSIS
|
||||
# =================================================================
|
||||
|
||||
log_step "Step 6: Detailed Token Analysis"
|
||||
|
||||
log_debug "Decoding access token from login..."
|
||||
|
||||
if [ -n "$ACCESS_TOKEN" ]; then
|
||||
# Decode JWT payload
|
||||
JWT_PAYLOAD=$(echo "$ACCESS_TOKEN" | cut -d. -f2)
|
||||
# Add padding if needed
|
||||
JWT_PAYLOAD="${JWT_PAYLOAD}$(printf '%*s' $((4 - ${#JWT_PAYLOAD} % 4)) | tr ' ' '=')"
|
||||
|
||||
echo "JWT Payload:"
|
||||
echo "$JWT_PAYLOAD" | base64 -d 2>/dev/null | python3 -m json.tool 2>/dev/null || echo "Failed to decode"
|
||||
|
||||
JWT_ROLE=$(echo "$JWT_PAYLOAD" | base64 -d 2>/dev/null | python3 -c "
|
||||
import json, sys
|
||||
try:
|
||||
data = json.load(sys.stdin)
|
||||
print(data.get('role', 'NOT_FOUND'))
|
||||
except:
|
||||
print('DECODE_ERROR')
|
||||
" 2>/dev/null)
|
||||
|
||||
echo ""
|
||||
log_debug "JWT token role: $JWT_ROLE"
|
||||
else
|
||||
log_error "No access token available for analysis"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
|
||||
# =================================================================
|
||||
# STEP 7: CHECK GATEWAY VS DIRECT AUTH
|
||||
# =================================================================
|
||||
|
||||
log_step "Step 7: Gateway vs Direct Auth Comparison"
|
||||
|
||||
log_debug "Testing direct auth service call..."
|
||||
|
||||
DIRECT_PROFILE=$(curl -s -X GET "http://localhost:8001/me" \
|
||||
-H "Authorization: Bearer $ACCESS_TOKEN" 2>/dev/null || echo "DIRECT_FAILED")
|
||||
|
||||
if [ "$DIRECT_PROFILE" != "DIRECT_FAILED" ]; then
|
||||
echo "Direct Auth Service Profile:"
|
||||
echo "$DIRECT_PROFILE" | python3 -m json.tool 2>/dev/null || echo "$DIRECT_PROFILE"
|
||||
|
||||
DIRECT_ROLE=$(extract_json_field "$DIRECT_PROFILE" "role")
|
||||
log_debug "Direct auth service role: $DIRECT_ROLE"
|
||||
else
|
||||
log_debug "Direct auth service call failed (expected in some setups)"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
|
||||
# =================================================================
|
||||
# STEP 8: CHECK AUTH SERVICE LOGS
|
||||
# =================================================================
|
||||
|
||||
log_step "Step 8: Auth Service Logs for This Session"
|
||||
|
||||
log_debug "Checking auth service logs for this user..."
|
||||
|
||||
AUTH_LOGS=$(docker logs --tail 50 bakery-auth-service 2>/dev/null | grep -E "($TEST_EMAIL|$USER_ID)" || echo "NO_LOGS_FOUND")
|
||||
|
||||
if [ "$AUTH_LOGS" != "NO_LOGS_FOUND" ]; then
|
||||
echo "Auth Service Logs (filtered for this user):"
|
||||
echo "$AUTH_LOGS"
|
||||
else
|
||||
log_debug "No specific logs found for this user"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
|
||||
# =================================================================
|
||||
# STEP 9: SUMMARY AND COMPARISON
|
||||
# =================================================================
|
||||
|
||||
log_step "Step 9: Onboarding Flow Analysis Summary"
|
||||
|
||||
echo -e "${CYAN}🔍 ONBOARDING FLOW ANALYSIS${NC}"
|
||||
echo -e "${CYAN}===========================${NC}"
|
||||
|
||||
echo ""
|
||||
echo "Registration Results:"
|
||||
echo " User ID: $USER_ID"
|
||||
echo " Access Token: $([ -n "$ACCESS_TOKEN" ] && echo 'YES' || echo 'NO')"
|
||||
|
||||
echo ""
|
||||
echo "Authentication Flow:"
|
||||
echo " Profile Role: $PROFILE_ROLE"
|
||||
echo " JWT Role: $JWT_ROLE"
|
||||
echo " Direct Auth Role: ${DIRECT_ROLE:-'NOT_TESTED'}"
|
||||
|
||||
echo ""
|
||||
echo "Admin Endpoint Tests:"
|
||||
echo " Deletion Preview: HTTP $HTTP_CODE"
|
||||
echo " Admin Access: $([ "$HTTP_CODE" = "200" ] && echo 'SUCCESS' || echo 'FAILED')"
|
||||
|
||||
echo ""
|
||||
if [ "$HTTP_CODE" != "200" ]; then
|
||||
echo -e "${RED}🚨 ISSUE IDENTIFIED:${NC}"
|
||||
echo "The onboarding flow is still getting 403 errors"
|
||||
echo ""
|
||||
echo -e "${YELLOW}Possible causes:${NC}"
|
||||
echo "1. Token from login different from registration token"
|
||||
echo "2. Gateway headers not set correctly"
|
||||
echo "3. Role being overridden somewhere in the flow"
|
||||
echo "4. Cache or session issue"
|
||||
echo ""
|
||||
echo -e "${YELLOW}Next steps:${NC}"
|
||||
echo "1. Compare JWT tokens from registration vs login"
|
||||
echo "2. Check gateway request headers"
|
||||
echo "3. Verify auth service restart picked up the fix"
|
||||
echo "4. Check if there are multiple auth service instances"
|
||||
else
|
||||
log_success "Onboarding flow working correctly!"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo -e "${CYAN}Manual commands to investigate:${NC}"
|
||||
echo "# Check user in database:"
|
||||
echo "docker exec bakery-auth-db psql -U auth_user -d auth_db -c \"SELECT id, email, role FROM users WHERE email='$TEST_EMAIL';\""
|
||||
echo ""
|
||||
echo "# Restart auth service:"
|
||||
echo "docker-compose restart auth-service"
|
||||
echo ""
|
||||
echo "# Check auth service status:"
|
||||
echo "docker-compose ps auth-service"
|
||||
@@ -1,157 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# =================================================================
|
||||
# DATABASE SCHEMA AND DATA VERIFICATION SCRIPT
|
||||
# =================================================================
|
||||
# This script checks the auth database schema and data
|
||||
|
||||
echo "🔍 DATABASE VERIFICATION"
|
||||
echo "========================"
|
||||
|
||||
# Colors
|
||||
GREEN='\033[0;32m'
|
||||
RED='\033[0;31m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m'
|
||||
|
||||
# Check if auth database container is running
|
||||
if ! docker ps | grep -q "bakery-auth-db"; then
|
||||
echo -e "${RED}❌ Auth database container is not running${NC}"
|
||||
echo "Start with: docker-compose up -d auth-db"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo -e "${GREEN}✅ Auth database container is running${NC}"
|
||||
echo ""
|
||||
|
||||
# 1. Check database schema
|
||||
echo "📋 1. CHECKING DATABASE SCHEMA"
|
||||
echo "=============================="
|
||||
|
||||
echo "Users table structure:"
|
||||
docker exec bakery-auth-db psql -U auth_user -d auth_db -c "\d users;" 2>/dev/null || {
|
||||
echo -e "${RED}❌ Cannot access database or users table doesn't exist${NC}"
|
||||
exit 1
|
||||
}
|
||||
|
||||
echo ""
|
||||
echo "Checking if 'role' column exists:"
|
||||
ROLE_COLUMN=$(docker exec bakery-auth-db psql -U auth_user -d auth_db -t -c "SELECT column_name FROM information_schema.columns WHERE table_name='users' AND column_name='role';" 2>/dev/null | tr -d ' ')
|
||||
|
||||
if [ "$ROLE_COLUMN" = "role" ]; then
|
||||
echo -e "${GREEN}✅ 'role' column exists in users table${NC}"
|
||||
else
|
||||
echo -e "${RED}❌ 'role' column is missing from users table${NC}"
|
||||
echo "This is likely the root cause!"
|
||||
echo ""
|
||||
echo "Available columns in users table:"
|
||||
docker exec bakery-auth-db psql -U auth_user -d auth_db -c "SELECT column_name, data_type FROM information_schema.columns WHERE table_name='users';"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo ""
|
||||
|
||||
# 2. Check existing users and their roles
|
||||
echo "📋 2. CHECKING EXISTING USERS"
|
||||
echo "============================="
|
||||
|
||||
echo "All users in database:"
|
||||
docker exec bakery-auth-db psql -U auth_user -d auth_db -c "SELECT id, email, role, is_active, created_at FROM users ORDER BY created_at DESC LIMIT 10;"
|
||||
|
||||
echo ""
|
||||
|
||||
# 3. Check for test users
|
||||
echo "📋 3. CHECKING FOR TEST USERS"
|
||||
echo "============================="
|
||||
|
||||
echo "Test users (containing 'test' in email):"
|
||||
docker exec bakery-auth-db psql -U auth_user -d auth_db -c "SELECT id, email, role, is_active, created_at FROM users WHERE email LIKE '%test%' ORDER BY created_at DESC;"
|
||||
|
||||
echo ""
|
||||
|
||||
# 4. Check database constraints and defaults
|
||||
echo "📋 4. CHECKING ROLE CONSTRAINTS"
|
||||
echo "==============================="
|
||||
|
||||
echo "Role column details:"
|
||||
docker exec bakery-auth-db psql -U auth_user -d auth_db -c "SELECT column_name, data_type, is_nullable, column_default FROM information_schema.columns WHERE table_name='users' AND column_name='role';"
|
||||
|
||||
echo ""
|
||||
echo "Role check constraints (if any):"
|
||||
docker exec bakery-auth-db psql -U auth_user -d auth_db -c "SELECT conname, consrc FROM pg_constraint WHERE conrelid = 'users'::regclass AND consrc LIKE '%role%';" 2>/dev/null || echo "No role constraints found"
|
||||
|
||||
echo ""
|
||||
|
||||
# 5. Test role insertion
|
||||
echo "📋 5. TESTING ROLE INSERTION"
|
||||
echo "============================"
|
||||
|
||||
TEST_EMAIL="schema.test.$(date +%s)@example.com"
|
||||
|
||||
echo "Creating test user with admin role:"
|
||||
docker exec bakery-auth-db psql -U auth_user -d auth_db -c "
|
||||
INSERT INTO users (id, email, full_name, hashed_password, role, is_active, is_verified, created_at, updated_at)
|
||||
VALUES (gen_random_uuid(), '$TEST_EMAIL', 'Schema Test', 'dummy_hash', 'admin', true, false, NOW(), NOW());
|
||||
" 2>/dev/null
|
||||
|
||||
if [ $? -eq 0 ]; then
|
||||
echo -e "${GREEN}✅ Successfully inserted user with admin role${NC}"
|
||||
|
||||
echo "Verifying insertion:"
|
||||
docker exec bakery-auth-db psql -U auth_user -d auth_db -c "SELECT id, email, role FROM users WHERE email='$TEST_EMAIL';"
|
||||
|
||||
echo "Cleaning up test user:"
|
||||
docker exec bakery-auth-db psql -U auth_user -d auth_db -c "DELETE FROM users WHERE email='$TEST_EMAIL';" 2>/dev/null
|
||||
else
|
||||
echo -e "${RED}❌ Failed to insert user with admin role${NC}"
|
||||
echo "This indicates a database constraint or permission issue"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
|
||||
# 6. Check for migration history
|
||||
echo "📋 6. CHECKING MIGRATION HISTORY"
|
||||
echo "================================="
|
||||
|
||||
echo "Alembic version table (if exists):"
|
||||
docker exec bakery-auth-db psql -U auth_user -d auth_db -c "SELECT * FROM alembic_version;" 2>/dev/null || echo "No alembic_version table found"
|
||||
|
||||
echo ""
|
||||
|
||||
echo "📋 SUMMARY AND RECOMMENDATIONS"
|
||||
echo "==============================="
|
||||
|
||||
# Check if we found any obvious issues
|
||||
ISSUES_FOUND=0
|
||||
|
||||
# Check if role column exists
|
||||
if [ "$ROLE_COLUMN" != "role" ]; then
|
||||
echo -e "${RED}❌ CRITICAL: 'role' column missing from users table${NC}"
|
||||
echo " → Run database migrations: alembic upgrade head"
|
||||
echo " → Or add the column manually"
|
||||
ISSUES_FOUND=1
|
||||
fi
|
||||
|
||||
# Check if we can insert admin roles
|
||||
docker exec bakery-auth-db psql -U auth_user -d auth_db -c "INSERT INTO users (id, email, full_name, hashed_password, role, is_active, is_verified, created_at, updated_at) VALUES (gen_random_uuid(), 'temp.test@example.com', 'Test', 'hash', 'admin', true, false, NOW(), NOW());" 2>/dev/null
|
||||
if [ $? -eq 0 ]; then
|
||||
docker exec bakery-auth-db psql -U auth_user -d auth_db -c "DELETE FROM users WHERE email='temp.test@example.com';" 2>/dev/null
|
||||
else
|
||||
echo -e "${RED}❌ ISSUE: Cannot insert users with admin role${NC}"
|
||||
echo " → Check database constraints or permissions"
|
||||
ISSUES_FOUND=1
|
||||
fi
|
||||
|
||||
if [ $ISSUES_FOUND -eq 0 ]; then
|
||||
echo -e "${GREEN}✅ Database schema appears correct${NC}"
|
||||
echo " → The issue is likely in the application code, not the database"
|
||||
echo " → Check JWT token creation and role extraction logic"
|
||||
else
|
||||
echo -e "${YELLOW}⚠️ Database issues found - fix these first${NC}"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo -e "${YELLOW}🔧 Next steps:${NC}"
|
||||
echo "1. If role column is missing: Run 'alembic upgrade head' in auth service"
|
||||
echo "2. If schema is OK: Run the main debug script to check application logic"
|
||||
echo "3. Check auth service logs during user registration"
|
||||
@@ -1,147 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Clean Structure Verification Script
|
||||
Verifies that all services can import their key components correctly after cleanup
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
|
||||
def test_import(module_path, module_name):
|
||||
"""Test if a module can be imported without errors"""
|
||||
try:
|
||||
spec = importlib.util.spec_from_file_location(module_name, module_path)
|
||||
if spec is None:
|
||||
return False, f"Could not create module spec for {module_path}"
|
||||
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return True, "Import successful"
|
||||
except Exception as e:
|
||||
return False, str(e)
|
||||
|
||||
def verify_service_structure(service_name, base_path):
|
||||
"""Verify the structure of a specific service"""
|
||||
print(f"\n=== Verifying {service_name.upper()} Service Structure ===")
|
||||
|
||||
service_path = base_path / f"services/{service_name}"
|
||||
|
||||
# Key files to check
|
||||
key_files = [
|
||||
"app/main.py",
|
||||
"app/core/config.py",
|
||||
"app/core/database.py"
|
||||
]
|
||||
|
||||
# API files (if they exist)
|
||||
api_files = []
|
||||
api_path = service_path / "app/api"
|
||||
if api_path.exists():
|
||||
for api_file in api_path.glob("*.py"):
|
||||
if api_file.name != "__init__.py":
|
||||
api_files.append(f"app/api/{api_file.name}")
|
||||
|
||||
# Service files (if they exist)
|
||||
service_files = []
|
||||
services_path = service_path / "app/services"
|
||||
if services_path.exists():
|
||||
for service_file in services_path.glob("*.py"):
|
||||
if service_file.name != "__init__.py":
|
||||
service_files.append(f"app/services/{service_file.name}")
|
||||
|
||||
all_files = key_files + api_files + service_files
|
||||
|
||||
results = {"success": 0, "failed": 0, "details": []}
|
||||
|
||||
for file_path in all_files:
|
||||
full_path = service_path / file_path
|
||||
if not full_path.exists():
|
||||
results["details"].append(f"❌ {file_path} - File does not exist")
|
||||
results["failed"] += 1
|
||||
continue
|
||||
|
||||
# Basic syntax check by attempting to compile
|
||||
try:
|
||||
with open(full_path, 'r') as f:
|
||||
content = f.read()
|
||||
compile(content, str(full_path), 'exec')
|
||||
results["details"].append(f"✅ {file_path} - Syntax OK")
|
||||
results["success"] += 1
|
||||
except SyntaxError as e:
|
||||
results["details"].append(f"❌ {file_path} - Syntax Error: {e}")
|
||||
results["failed"] += 1
|
||||
except Exception as e:
|
||||
results["details"].append(f"⚠️ {file_path} - Warning: {e}")
|
||||
results["success"] += 1 # Still count as success for non-syntax issues
|
||||
|
||||
# Print results
|
||||
for detail in results["details"]:
|
||||
print(f" {detail}")
|
||||
|
||||
success_rate = results["success"] / (results["success"] + results["failed"]) * 100 if (results["success"] + results["failed"]) > 0 else 0
|
||||
print(f"\n{service_name.upper()} Results: {results['success']} ✅ | {results['failed']} ❌ | {success_rate:.1f}% success")
|
||||
|
||||
return results["failed"] == 0
|
||||
|
||||
def main():
|
||||
"""Main verification function"""
|
||||
print("🔍 DATABASE ARCHITECTURE REFACTORING - CLEAN STRUCTURE VERIFICATION")
|
||||
print("=" * 70)
|
||||
|
||||
base_path = Path(__file__).parent
|
||||
|
||||
# Services to verify
|
||||
services = ["data", "auth", "training", "forecasting", "tenant", "notification"]
|
||||
|
||||
all_services_ok = True
|
||||
|
||||
for service in services:
|
||||
service_ok = verify_service_structure(service, base_path)
|
||||
if not service_ok:
|
||||
all_services_ok = False
|
||||
|
||||
# Verify shared components
|
||||
print(f"\n=== Verifying SHARED Components ===")
|
||||
shared_files = [
|
||||
"shared/database/base.py",
|
||||
"shared/database/repository.py",
|
||||
"shared/database/unit_of_work.py",
|
||||
"shared/database/transactions.py",
|
||||
"shared/database/exceptions.py",
|
||||
"shared/clients/base_service_client.py"
|
||||
]
|
||||
|
||||
shared_ok = True
|
||||
for file_path in shared_files:
|
||||
full_path = base_path / file_path
|
||||
if not full_path.exists():
|
||||
print(f" ❌ {file_path} - File does not exist")
|
||||
shared_ok = False
|
||||
continue
|
||||
|
||||
try:
|
||||
with open(full_path, 'r') as f:
|
||||
content = f.read()
|
||||
compile(content, str(full_path), 'exec')
|
||||
print(f" ✅ {file_path} - Syntax OK")
|
||||
except Exception as e:
|
||||
print(f" ❌ {file_path} - Error: {e}")
|
||||
shared_ok = False
|
||||
|
||||
# Final summary
|
||||
print(f"\n" + "=" * 70)
|
||||
if all_services_ok and shared_ok:
|
||||
print("🎉 VERIFICATION SUCCESSFUL - All services have clean structure!")
|
||||
print("✅ All enhanced_*.py files removed")
|
||||
print("✅ All imports updated to use new structure")
|
||||
print("✅ All syntax checks passed")
|
||||
return 0
|
||||
else:
|
||||
print("❌ VERIFICATION FAILED - Issues found in service structure")
|
||||
return 1
|
||||
|
||||
if __name__ == "__main__":
|
||||
exit_code = main()
|
||||
sys.exit(exit_code)
|
||||
Reference in New Issue
Block a user