ADD new frontend 2
This commit is contained in:
@@ -1,23 +0,0 @@
|
|||||||
# API Configuration
|
|
||||||
VITE_API_URL=http://localhost:8000/api/v1
|
|
||||||
VITE_API_TIMEOUT=30000
|
|
||||||
VITE_API_RETRIES=3
|
|
||||||
VITE_API_RETRY_DELAY=1000
|
|
||||||
VITE_API_LOGGING=true
|
|
||||||
VITE_API_CACHING=true
|
|
||||||
VITE_API_CACHE_TIMEOUT=300000
|
|
||||||
|
|
||||||
# Feature Flags
|
|
||||||
VITE_ENABLE_WEBSOCKETS=false
|
|
||||||
VITE_ENABLE_OFFLINE=false
|
|
||||||
VITE_ENABLE_OPTIMISTIC_UPDATES=true
|
|
||||||
VITE_ENABLE_DEDUPLICATION=true
|
|
||||||
VITE_ENABLE_METRICS=false
|
|
||||||
|
|
||||||
# Stripe Configuration (Spanish Market)
|
|
||||||
VITE_STRIPE_PUBLISHABLE_KEY=pk_test_example_key_for_development
|
|
||||||
VITE_STRIPE_WEBHOOK_SECRET=whsec_example_webhook_secret_for_development
|
|
||||||
|
|
||||||
# Development Flags
|
|
||||||
VITE_BYPASS_PAYMENT=true
|
|
||||||
VITE_DEV_MODE=true
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
# API Configuration
|
|
||||||
VITE_API_URL=https://api.pania.es/api/v1
|
|
||||||
VITE_API_TIMEOUT=30000
|
|
||||||
VITE_API_RETRIES=3
|
|
||||||
VITE_API_RETRY_DELAY=1000
|
|
||||||
VITE_API_LOGGING=false
|
|
||||||
VITE_API_CACHING=true
|
|
||||||
VITE_API_CACHE_TIMEOUT=300000
|
|
||||||
|
|
||||||
# Feature Flags
|
|
||||||
VITE_ENABLE_WEBSOCKETS=true
|
|
||||||
VITE_ENABLE_OFFLINE=true
|
|
||||||
VITE_ENABLE_OPTIMISTIC_UPDATES=true
|
|
||||||
VITE_ENABLE_DEDUPLICATION=true
|
|
||||||
VITE_ENABLE_METRICS=true
|
|
||||||
|
|
||||||
# Stripe Configuration (Spanish Market)
|
|
||||||
VITE_STRIPE_PUBLISHABLE_KEY=pk_live_your_production_stripe_key
|
|
||||||
VITE_STRIPE_WEBHOOK_SECRET=whsec_your_production_webhook_secret
|
|
||||||
|
|
||||||
# Development Flags (DISABLED IN PRODUCTION)
|
|
||||||
VITE_BYPASS_PAYMENT=false
|
|
||||||
VITE_DEV_MODE=false
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
# frontend/Dockerfile.development - FIXED VERSION
|
|
||||||
FROM node:18-alpine
|
|
||||||
|
|
||||||
# Install curl for healthchecks
|
|
||||||
RUN apk add --no-cache curl
|
|
||||||
|
|
||||||
# Set working directory
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
# Create non-root user for security but don't switch yet
|
|
||||||
RUN addgroup -g 1001 -S nodejs && \
|
|
||||||
adduser -S reactjs -u 1001 -G nodejs
|
|
||||||
|
|
||||||
# Copy package files first (better caching)
|
|
||||||
COPY package*.json ./
|
|
||||||
|
|
||||||
# Install all dependencies (including dev dependencies) as root
|
|
||||||
RUN npm ci --verbose && \
|
|
||||||
npm cache clean --force
|
|
||||||
|
|
||||||
# Copy source code
|
|
||||||
COPY . .
|
|
||||||
|
|
||||||
# Change ownership of all files to the non-root user
|
|
||||||
RUN chown -R reactjs:nodejs /app
|
|
||||||
|
|
||||||
# Now switch to non-root user
|
|
||||||
USER reactjs
|
|
||||||
|
|
||||||
# Expose port 3000 (Vite default)
|
|
||||||
EXPOSE 3000
|
|
||||||
|
|
||||||
# Add healthcheck
|
|
||||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \
|
|
||||||
CMD curl -f http://localhost:3000/ || exit 1
|
|
||||||
|
|
||||||
# Start development server with host binding
|
|
||||||
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"]
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
# frontend/Dockerfile.production
|
|
||||||
# Multi-stage build for production
|
|
||||||
|
|
||||||
# Build stage
|
|
||||||
FROM node:18-alpine as builder
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
# Copy package files
|
|
||||||
COPY package*.json ./
|
|
||||||
|
|
||||||
# Install dependencies
|
|
||||||
RUN npm ci --only=production
|
|
||||||
|
|
||||||
# Copy source code
|
|
||||||
COPY . .
|
|
||||||
|
|
||||||
# Build the application
|
|
||||||
RUN npm run build
|
|
||||||
|
|
||||||
# Production stage
|
|
||||||
FROM nginx:alpine
|
|
||||||
|
|
||||||
# Install curl for healthchecks
|
|
||||||
RUN apk add --no-cache curl
|
|
||||||
|
|
||||||
# Copy built app from builder stage
|
|
||||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
|
||||||
|
|
||||||
# Copy nginx configuration
|
|
||||||
COPY nginx.conf /etc/nginx/nginx.conf
|
|
||||||
|
|
||||||
# Expose port 80
|
|
||||||
EXPOSE 80
|
|
||||||
|
|
||||||
# Add healthcheck
|
|
||||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
|
|
||||||
CMD curl -f http://localhost/ || exit 1
|
|
||||||
|
|
||||||
# Start nginx
|
|
||||||
CMD ["nginx", "-g", "daemon off;"]
|
|
||||||
@@ -1,170 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="es">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
|
|
||||||
<!-- Primary Meta Tags -->
|
|
||||||
<title>PanIA - Inteligencia Artificial para tu Panadería en Madrid</title>
|
|
||||||
<meta name="title" content="PanIA - Inteligencia Artificial para tu Panadería en Madrid" />
|
|
||||||
<meta name="description" content="La primera IA diseñada para panaderías españolas. Reduce desperdicios hasta un 25%, aumenta ganancias y optimiza producción con predicciones precisas en Madrid." />
|
|
||||||
<meta name="keywords" content="inteligencia artificial panadería, predicción ventas panadería, IA para panaderías Madrid, sistema predicción panadería, optimización panadería, reducir desperdicios panadería" />
|
|
||||||
<meta name="author" content="PanIA Team" />
|
|
||||||
<meta name="robots" content="index, follow" />
|
|
||||||
|
|
||||||
<!-- Open Graph / Facebook -->
|
|
||||||
<meta property="og:type" content="website" />
|
|
||||||
<meta property="og:url" content="https://pania.es/" />
|
|
||||||
<meta property="og:title" content="PanIA - Inteligencia Artificial para tu Panadería en Madrid" />
|
|
||||||
<meta property="og:description" content="La primera IA diseñada para panaderías españolas. Reduce desperdicios hasta un 25% y optimiza tu producción con predicciones precisas." />
|
|
||||||
<meta property="og:image" content="https://pania.es/og-image.jpg" />
|
|
||||||
<meta property="og:locale" content="es_ES" />
|
|
||||||
<meta property="og:site_name" content="PanIA" />
|
|
||||||
|
|
||||||
<!-- Twitter -->
|
|
||||||
<meta property="twitter:card" content="summary_large_image" />
|
|
||||||
<meta property="twitter:url" content="https://pania.es/" />
|
|
||||||
<meta property="twitter:title" content="PanIA - Inteligencia Artificial para tu Panadería en Madrid" />
|
|
||||||
<meta property="twitter:description" content="La primera IA diseñada para panaderías españolas. Reduce desperdicios hasta un 25% y optimiza tu producción con predicciones precisas." />
|
|
||||||
<meta property="twitter:image" content="https://pania.es/twitter-image.jpg" />
|
|
||||||
|
|
||||||
<!-- Favicon -->
|
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
|
||||||
<link rel="icon" type="image/png" href="/favicon.png" />
|
|
||||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
|
||||||
|
|
||||||
<!-- Preconnect to important domains -->
|
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
|
||||||
|
|
||||||
<!-- Google Fonts -->
|
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Poppins:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
|
||||||
|
|
||||||
<!-- Local Business Schema.org markup -->
|
|
||||||
<script type="application/ld+json">
|
|
||||||
{
|
|
||||||
"@context": "https://schema.org",
|
|
||||||
"@type": "SoftwareApplication",
|
|
||||||
"name": "PanIA",
|
|
||||||
"description": "Inteligencia Artificial para panaderías en Madrid",
|
|
||||||
"url": "https://pania.es",
|
|
||||||
"applicationCategory": "BusinessApplication",
|
|
||||||
"operatingSystem": "Web",
|
|
||||||
"offers": {
|
|
||||||
"@type": "Offer",
|
|
||||||
"price": "0",
|
|
||||||
"priceCurrency": "EUR",
|
|
||||||
"description": "30 días de prueba gratuita"
|
|
||||||
},
|
|
||||||
"provider": {
|
|
||||||
"@type": "Organization",
|
|
||||||
"name": "PanIA",
|
|
||||||
"url": "https://pania.es",
|
|
||||||
"address": {
|
|
||||||
"@type": "PostalAddress",
|
|
||||||
"addressLocality": "Madrid",
|
|
||||||
"@country": "España"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"targetAudience": {
|
|
||||||
"@type": "Audience",
|
|
||||||
"name": "Panaderías en Madrid"
|
|
||||||
},
|
|
||||||
"featureList": [
|
|
||||||
"Predicciones de demanda con IA",
|
|
||||||
"Reducción de desperdicios",
|
|
||||||
"Optimización de producción",
|
|
||||||
"Gestión inteligente de pedidos"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<!-- Performance and Analytics -->
|
|
||||||
<link rel="dns-prefetch" href="//api.pania.es" />
|
|
||||||
|
|
||||||
<!-- Prevent FOUC (Flash of Unstyled Content) -->
|
|
||||||
<style>
|
|
||||||
/* Critical CSS for initial paint */
|
|
||||||
body {
|
|
||||||
font-family: 'Inter', system-ui, -apple-system, sans-serif;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
background-color: #fafafa;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Loading spinner for initial load */
|
|
||||||
.initial-loading {
|
|
||||||
position: fixed;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
background: linear-gradient(135deg, #fff7ed 0%, #fed7aa 100%);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
z-index: 9999;
|
|
||||||
}
|
|
||||||
|
|
||||||
.loading-content {
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bakery-icon {
|
|
||||||
width: 80px;
|
|
||||||
height: 80px;
|
|
||||||
background: #f97316;
|
|
||||||
border-radius: 20px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
margin: 0 auto 20px;
|
|
||||||
font-size: 32px;
|
|
||||||
animation: pulse 2s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
.loading-text {
|
|
||||||
color: #9a3412;
|
|
||||||
font-size: 18px;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes pulse {
|
|
||||||
0%, 100% { transform: scale(1); }
|
|
||||||
50% { transform: scale(1.05); }
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<!-- Initial loading screen -->
|
|
||||||
<div id="initial-loading" class="initial-loading">
|
|
||||||
<div class="loading-content">
|
|
||||||
<div class="bakery-icon">🥖</div>
|
|
||||||
<div class="loading-text">Cargando PanIA...</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- React app container -->
|
|
||||||
<div id="root"></div>
|
|
||||||
|
|
||||||
<!-- Hide loading screen once React loads -->
|
|
||||||
<script>
|
|
||||||
// Hide loading screen when React app is ready
|
|
||||||
window.addEventListener('DOMContentLoaded', function() {
|
|
||||||
setTimeout(function() {
|
|
||||||
const loadingScreen = document.getElementById('initial-loading');
|
|
||||||
if (loadingScreen) {
|
|
||||||
loadingScreen.style.opacity = '0';
|
|
||||||
loadingScreen.style.transition = 'opacity 0.5s ease-out';
|
|
||||||
setTimeout(() => {
|
|
||||||
loadingScreen.style.display = 'none';
|
|
||||||
}, 500);
|
|
||||||
}
|
|
||||||
}, 1000); // Show loading for at least 1 second
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<!-- Main application script -->
|
|
||||||
<script type="module" src="/src/main.tsx"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
# frontend/nginx.conf
|
|
||||||
events {
|
|
||||||
worker_connections 1024;
|
|
||||||
}
|
|
||||||
|
|
||||||
http {
|
|
||||||
include /etc/nginx/mime.types;
|
|
||||||
default_type application/octet-stream;
|
|
||||||
|
|
||||||
# Enable gzip compression
|
|
||||||
gzip on;
|
|
||||||
gzip_vary on;
|
|
||||||
gzip_min_length 1024;
|
|
||||||
gzip_proxied any;
|
|
||||||
gzip_comp_level 6;
|
|
||||||
gzip_types
|
|
||||||
text/plain
|
|
||||||
text/css
|
|
||||||
text/xml
|
|
||||||
text/javascript
|
|
||||||
application/json
|
|
||||||
application/javascript
|
|
||||||
application/xml+rss
|
|
||||||
application/atom+xml
|
|
||||||
image/svg+xml;
|
|
||||||
|
|
||||||
server {
|
|
||||||
listen 80;
|
|
||||||
server_name localhost;
|
|
||||||
root /usr/share/nginx/html;
|
|
||||||
index index.html;
|
|
||||||
|
|
||||||
# Security headers
|
|
||||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
|
||||||
add_header X-Content-Type-Options "nosniff" always;
|
|
||||||
add_header X-XSS-Protection "1; mode=block" always;
|
|
||||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
|
||||||
|
|
||||||
# Handle React Router
|
|
||||||
location / {
|
|
||||||
try_files $uri $uri/ /index.html;
|
|
||||||
}
|
|
||||||
|
|
||||||
# API proxy to backend
|
|
||||||
location /api/ {
|
|
||||||
proxy_pass http://bakery-gateway:8000/api/;
|
|
||||||
proxy_http_version 1.1;
|
|
||||||
proxy_set_header Upgrade $http_upgrade;
|
|
||||||
proxy_set_header Connection 'upgrade';
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
|
||||||
proxy_cache_bypass $http_upgrade;
|
|
||||||
}
|
|
||||||
|
|
||||||
# Cache static assets
|
|
||||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
|
||||||
expires 1y;
|
|
||||||
add_header Cache-Control "public, immutable";
|
|
||||||
}
|
|
||||||
|
|
||||||
# Health check endpoint
|
|
||||||
location /health {
|
|
||||||
access_log off;
|
|
||||||
return 200 "healthy\n";
|
|
||||||
add_header Content-Type text/plain;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
7652
fdev-ffrontend/package-lock.json
generated
7652
fdev-ffrontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,75 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "pania-frontend",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"description": "AI-powered bakery demand forecasting platform for Madrid",
|
|
||||||
"type": "module",
|
|
||||||
"scripts": {
|
|
||||||
"dev": "vite",
|
|
||||||
"build": "tsc && vite build",
|
|
||||||
"preview": "vite preview",
|
|
||||||
"test": "vitest",
|
|
||||||
"test:ui": "vitest --ui",
|
|
||||||
"test:coverage": "vitest --coverage",
|
|
||||||
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
|
||||||
"lint:fix": "eslint . --ext ts,tsx --fix"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@hookform/resolvers": "^3.3.1",
|
|
||||||
"@reduxjs/toolkit": "^1.9.5",
|
|
||||||
"@stripe/react-stripe-js": "^3.9.0",
|
|
||||||
"@stripe/stripe-js": "^7.8.0",
|
|
||||||
"clsx": "^2.0.0",
|
|
||||||
"date-fns": "^2.30.0",
|
|
||||||
"date-fns-tz": "^2.0.0",
|
|
||||||
"i18next": "^23.4.4",
|
|
||||||
"i18next-browser-languagedetector": "^7.1.0",
|
|
||||||
"lucide-react": "^0.263.1",
|
|
||||||
"react": "^18.2.0",
|
|
||||||
"react-dom": "^18.2.0",
|
|
||||||
"react-hook-form": "^7.45.4",
|
|
||||||
"react-hot-toast": "^2.4.1",
|
|
||||||
"react-i18next": "^13.1.2",
|
|
||||||
"react-redux": "^8.1.2",
|
|
||||||
"react-router-dom": "^6.15.0",
|
|
||||||
"recharts": "^2.8.0",
|
|
||||||
"tailwind-merge": "^1.14.0",
|
|
||||||
"zod": "^3.22.2"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@tailwindcss/forms": "^0.5.4",
|
|
||||||
"@tailwindcss/typography": "^0.5.9",
|
|
||||||
"@testing-library/jest-dom": "^5.17.0",
|
|
||||||
"@testing-library/react": "^13.4.0",
|
|
||||||
"@testing-library/user-event": "^14.4.3",
|
|
||||||
"@types/react": "^18.2.15",
|
|
||||||
"@types/react-dom": "^18.2.7",
|
|
||||||
"@typescript-eslint/eslint-plugin": "^6.0.0",
|
|
||||||
"@typescript-eslint/parser": "^6.0.0",
|
|
||||||
"@vitejs/plugin-react": "^4.0.3",
|
|
||||||
"@vitest/ui": "^0.34.1",
|
|
||||||
"autoprefixer": "^10.4.14",
|
|
||||||
"eslint": "^8.45.0",
|
|
||||||
"eslint-plugin-react-hooks": "^4.6.0",
|
|
||||||
"eslint-plugin-react-refresh": "^0.4.3",
|
|
||||||
"postcss": "^8.4.27",
|
|
||||||
"tailwindcss": "^3.3.0",
|
|
||||||
"typescript": "^5.0.2",
|
|
||||||
"vite": "^4.4.5",
|
|
||||||
"vitest": "^0.34.1"
|
|
||||||
},
|
|
||||||
"keywords": [
|
|
||||||
"bakery",
|
|
||||||
"forecasting",
|
|
||||||
"ai",
|
|
||||||
"madrid",
|
|
||||||
"react",
|
|
||||||
"typescript",
|
|
||||||
"tailwind"
|
|
||||||
],
|
|
||||||
"repository": {
|
|
||||||
"type": "git",
|
|
||||||
"url": "https://github.com/pania-es/frontend"
|
|
||||||
},
|
|
||||||
"author": "PanIA Team",
|
|
||||||
"license": "MIT"
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
export default {
|
|
||||||
plugins: {
|
|
||||||
tailwindcss: {},
|
|
||||||
autoprefixer: {},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
import React, { useEffect } from 'react';
|
|
||||||
import { RouterProvider } from 'react-router-dom';
|
|
||||||
import { Provider } from 'react-redux';
|
|
||||||
import { Toaster } from 'react-hot-toast';
|
|
||||||
import { router } from './router';
|
|
||||||
import { store } from './store';
|
|
||||||
import ErrorBoundary from './components/ErrorBoundary';
|
|
||||||
import { useAuth } from './hooks/useAuth';
|
|
||||||
|
|
||||||
// i18n
|
|
||||||
import './i18n';
|
|
||||||
|
|
||||||
// Global styles
|
|
||||||
import './styles/globals.css';
|
|
||||||
|
|
||||||
const AppContent: React.FC = () => {
|
|
||||||
const { initializeAuth } = useAuth();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
initializeAuth();
|
|
||||||
}, [initializeAuth]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ErrorBoundary>
|
|
||||||
<div className="App min-h-screen bg-gray-50">
|
|
||||||
<RouterProvider router={router} />
|
|
||||||
|
|
||||||
{/* Global Toast Notifications */}
|
|
||||||
<Toaster
|
|
||||||
position="top-right"
|
|
||||||
toastOptions={{
|
|
||||||
duration: 4000,
|
|
||||||
style: {
|
|
||||||
background: '#fff',
|
|
||||||
color: '#333',
|
|
||||||
boxShadow: '0 4px 25px -5px rgba(0, 0, 0, 0.1)',
|
|
||||||
borderRadius: '12px',
|
|
||||||
padding: '16px',
|
|
||||||
},
|
|
||||||
success: {
|
|
||||||
iconTheme: {
|
|
||||||
primary: '#22c55e',
|
|
||||||
secondary: '#fff',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
error: {
|
|
||||||
iconTheme: {
|
|
||||||
primary: '#ef4444',
|
|
||||||
secondary: '#fff',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</ErrorBoundary>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const App: React.FC = () => {
|
|
||||||
return (
|
|
||||||
<Provider store={store}>
|
|
||||||
<AppContent />
|
|
||||||
</Provider>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default App;
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
frontend/src/api/
|
|
||||||
├── client/ # HTTP client configuration
|
|
||||||
│ ├── index.ts # Main API client
|
|
||||||
│ ├── config.ts # Client configuration
|
|
||||||
│ ├── interceptors.ts # Request/response interceptors
|
|
||||||
│ └── types.ts # Client-specific types
|
|
||||||
├── services/ # Service-specific API calls
|
|
||||||
│ ├── index.ts # Export all services
|
|
||||||
│ ├── auth.service.ts # Authentication operations
|
|
||||||
│ ├── tenant.service.ts # Tenant management
|
|
||||||
│ ├── data.service.ts # Data operations
|
|
||||||
│ ├── training.service.ts # ML training operations
|
|
||||||
│ ├── forecasting.service.ts # Forecasting operations
|
|
||||||
│ └── notification.service.ts # Notification operations
|
|
||||||
├── types/ # TypeScript definitions
|
|
||||||
│ ├── index.ts # Re-export all types
|
|
||||||
│ ├── common.ts # Common API types
|
|
||||||
│ ├── auth.ts # Authentication types
|
|
||||||
│ ├── tenant.ts # Tenant types
|
|
||||||
│ ├── data.ts # Data types
|
|
||||||
│ ├── training.ts # Training types
|
|
||||||
│ ├── forecasting.ts # Forecasting types
|
|
||||||
│ └── notification.ts # Notification types
|
|
||||||
├── hooks/ # React hooks for API calls
|
|
||||||
│ ├── index.ts # Export all hooks
|
|
||||||
│ ├── useAuth.ts # Authentication hooks
|
|
||||||
│ ├── useTenant.ts # Tenant hooks
|
|
||||||
│ ├── useData.ts # Data hooks
|
|
||||||
│ ├── useTraining.ts # Training hooks
|
|
||||||
│ ├── useForecast.ts # Forecasting hooks
|
|
||||||
│ └── useNotification.ts # Notification hooks
|
|
||||||
├── utils/ # API utilities
|
|
||||||
│ ├── index.ts # Export utilities
|
|
||||||
│ ├── response.ts # Response handling
|
|
||||||
│ ├── error.ts # Error handling
|
|
||||||
│ ├── validation.ts # Request validation
|
|
||||||
│ └── transform.ts # Data transformation
|
|
||||||
├── websocket/ # WebSocket management
|
|
||||||
│ ├── index.ts # WebSocket exports
|
|
||||||
│ ├── manager.ts # WebSocket manager
|
|
||||||
│ ├── types.ts # WebSocket types
|
|
||||||
│ └── hooks.ts # WebSocket hooks
|
|
||||||
└── index.ts # Main API exports
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🎯 Key Improvements
|
|
||||||
|
|
||||||
### 1. **Modern Architecture Patterns**
|
|
||||||
- **Service Layer Pattern**: Clean separation of concerns
|
|
||||||
- **Repository Pattern**: Consistent data access layer
|
|
||||||
- **Factory Pattern**: Flexible service instantiation
|
|
||||||
- **Observer Pattern**: Event-driven updates
|
|
||||||
|
|
||||||
### 2. **Type Safety**
|
|
||||||
- **Strict TypeScript**: Full type coverage
|
|
||||||
- **Schema Validation**: Runtime type checking
|
|
||||||
- **Generic Types**: Reusable type definitions
|
|
||||||
- **Union Types**: Precise API responses
|
|
||||||
|
|
||||||
### 3. **Error Handling**
|
|
||||||
- **Centralized Error Management**: Consistent error handling
|
|
||||||
- **Error Recovery**: Automatic retry mechanisms
|
|
||||||
- **User-Friendly Messages**: Localized error messages
|
|
||||||
- **Error Boundaries**: Component-level error isolation
|
|
||||||
|
|
||||||
### 4. **Performance Optimization**
|
|
||||||
- **Request Caching**: Intelligent cache management
|
|
||||||
- **Request Deduplication**: Prevent duplicate calls
|
|
||||||
- **Optimistic Updates**: Immediate UI feedback
|
|
||||||
- **Background Sync**: Offline-first approach
|
|
||||||
|
|
||||||
### 5. **Developer Experience**
|
|
||||||
- **Auto-completion**: Full IntelliSense support
|
|
||||||
- **Type-safe Hooks**: React hooks with types
|
|
||||||
- **Error Prevention**: Compile-time error detection
|
|
||||||
- **Documentation**: Comprehensive JSDoc comments
|
|
||||||
|
|
||||||
## 🚀 Implementation Benefits
|
|
||||||
|
|
||||||
1. **Maintainability**: Modular structure for easy updates
|
|
||||||
2. **Scalability**: Easy to add new services and endpoints
|
|
||||||
3. **Testability**: Isolated services for unit testing
|
|
||||||
4. **Reusability**: Shared utilities and types
|
|
||||||
5. **Type Safety**: Prevent runtime errors
|
|
||||||
6. **Developer Productivity**: IntelliSense and auto-completion
|
|
||||||
@@ -1,142 +0,0 @@
|
|||||||
// frontend/src/api/client/config.ts
|
|
||||||
/**
|
|
||||||
* API Client Configuration
|
|
||||||
* Centralized configuration for all API clients
|
|
||||||
*/
|
|
||||||
|
|
||||||
export interface ApiConfig {
|
|
||||||
baseURL: string;
|
|
||||||
timeout: number;
|
|
||||||
retries: number;
|
|
||||||
retryDelay: number;
|
|
||||||
enableLogging: boolean;
|
|
||||||
enableCaching: boolean;
|
|
||||||
cacheTimeout: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ServiceEndpoints {
|
|
||||||
auth: string;
|
|
||||||
tenant: string;
|
|
||||||
data: string;
|
|
||||||
training: string;
|
|
||||||
forecasting: string;
|
|
||||||
notification: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Environment-based configuration
|
|
||||||
const getEnvironmentConfig = (): ApiConfig => {
|
|
||||||
// Use import.meta.env instead of process.env for Vite
|
|
||||||
const isDevelopment = import.meta.env.DEV;
|
|
||||||
const isProduction = import.meta.env.PROD;
|
|
||||||
|
|
||||||
return {
|
|
||||||
baseURL: import.meta.env.VITE_API_URL || 'http://localhost:8000/api/v1',
|
|
||||||
timeout: parseInt(import.meta.env.VITE_API_TIMEOUT || '30000'),
|
|
||||||
retries: parseInt(import.meta.env.VITE_API_RETRIES || '3'),
|
|
||||||
retryDelay: parseInt(import.meta.env.VITE_API_RETRY_DELAY || '1000'),
|
|
||||||
enableLogging: isDevelopment || import.meta.env.VITE_API_LOGGING === 'true',
|
|
||||||
enableCaching: import.meta.env.VITE_API_CACHING !== 'false',
|
|
||||||
cacheTimeout: parseInt(import.meta.env.VITE_API_CACHE_TIMEOUT || '300000'), // 5 minutes
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export const apiConfig: ApiConfig = getEnvironmentConfig();
|
|
||||||
|
|
||||||
// Service endpoint configuration
|
|
||||||
export const serviceEndpoints: ServiceEndpoints = {
|
|
||||||
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
|
|
||||||
};
|
|
||||||
|
|
||||||
// HTTP status codes
|
|
||||||
export const HttpStatus = {
|
|
||||||
OK: 200,
|
|
||||||
CREATED: 201,
|
|
||||||
NO_CONTENT: 204,
|
|
||||||
BAD_REQUEST: 400,
|
|
||||||
UNAUTHORIZED: 401,
|
|
||||||
FORBIDDEN: 403,
|
|
||||||
NOT_FOUND: 404,
|
|
||||||
CONFLICT: 409,
|
|
||||||
UNPROCESSABLE_ENTITY: 422,
|
|
||||||
INTERNAL_SERVER_ERROR: 500,
|
|
||||||
BAD_GATEWAY: 502,
|
|
||||||
SERVICE_UNAVAILABLE: 503,
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
// Request timeout configuration
|
|
||||||
export const RequestTimeouts = {
|
|
||||||
SHORT: 5000, // 5 seconds - for quick operations
|
|
||||||
MEDIUM: 15000, // 15 seconds - for normal operations
|
|
||||||
LONG: 60000, // 1 minute - for file uploads
|
|
||||||
EXTENDED: 300000, // 5 minutes - for training operations
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
// Cache configuration
|
|
||||||
export interface CacheConfig {
|
|
||||||
defaultTTL: number;
|
|
||||||
maxSize: number;
|
|
||||||
strategies: {
|
|
||||||
user: number;
|
|
||||||
tenant: number;
|
|
||||||
data: number;
|
|
||||||
forecast: number;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export const cacheConfig: CacheConfig = {
|
|
||||||
defaultTTL: 300000, // 5 minutes
|
|
||||||
maxSize: 100, // Maximum cached items
|
|
||||||
strategies: {
|
|
||||||
user: 600000, // 10 minutes
|
|
||||||
tenant: 1800000, // 30 minutes
|
|
||||||
data: 300000, // 5 minutes
|
|
||||||
forecast: 600000, // 10 minutes
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// Retry configuration
|
|
||||||
export interface RetryConfig {
|
|
||||||
attempts: number;
|
|
||||||
delay: number;
|
|
||||||
backoff: number;
|
|
||||||
retryCondition: (error: any) => boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const retryConfig: RetryConfig = {
|
|
||||||
attempts: 3,
|
|
||||||
delay: 1000,
|
|
||||||
backoff: 2, // Exponential backoff multiplier
|
|
||||||
retryCondition: (error: any) => {
|
|
||||||
// Retry on network errors and specific HTTP status codes
|
|
||||||
if (!error.response) return true; // Network error
|
|
||||||
const status = error.response.status;
|
|
||||||
return status >= 500 || status === 408 || status === 429;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// API versioning
|
|
||||||
export const ApiVersion = {
|
|
||||||
V1: 'v1',
|
|
||||||
CURRENT: 'v1',
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
export interface FeatureFlags {
|
|
||||||
enableWebSockets: boolean;
|
|
||||||
enableOfflineMode: boolean;
|
|
||||||
enableOptimisticUpdates: boolean;
|
|
||||||
enableRequestDeduplication: boolean;
|
|
||||||
enableMetrics: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const featureFlags: FeatureFlags = {
|
|
||||||
enableWebSockets: import.meta.env.VITE_ENABLE_WEBSOCKETS === 'true',
|
|
||||||
enableOfflineMode: import.meta.env.VITE_ENABLE_OFFLINE === 'true',
|
|
||||||
enableOptimisticUpdates: import.meta.env.VITE_ENABLE_OPTIMISTIC_UPDATES !== 'false',
|
|
||||||
enableRequestDeduplication: import.meta.env.VITE_ENABLE_DEDUPLICATION !== 'false',
|
|
||||||
enableMetrics: import.meta.env.VITE_ENABLE_METRICS === 'true',
|
|
||||||
};
|
|
||||||
@@ -1,578 +0,0 @@
|
|||||||
// frontend/src/api/client/index.ts
|
|
||||||
/**
|
|
||||||
* Enhanced API Client with modern features
|
|
||||||
* Supports caching, retries, optimistic updates, and more
|
|
||||||
*/
|
|
||||||
|
|
||||||
import {
|
|
||||||
ApiResponse,
|
|
||||||
ApiError,
|
|
||||||
RequestConfig,
|
|
||||||
UploadConfig,
|
|
||||||
UploadProgress,
|
|
||||||
RequestInterceptor,
|
|
||||||
ResponseInterceptor,
|
|
||||||
CacheEntry,
|
|
||||||
RequestMetrics,
|
|
||||||
} from './types';
|
|
||||||
import { apiConfig, retryConfig, cacheConfig, featureFlags } from './config';
|
|
||||||
|
|
||||||
export class ApiClient {
|
|
||||||
private baseURL: string;
|
|
||||||
private cache = new Map<string, CacheEntry>();
|
|
||||||
private pendingRequests = new Map<string, Promise<any>>();
|
|
||||||
private requestInterceptors: RequestInterceptor[] = [];
|
|
||||||
private responseInterceptors: ResponseInterceptor[] = [];
|
|
||||||
private metrics: RequestMetrics[] = [];
|
|
||||||
|
|
||||||
constructor(baseURL?: string) {
|
|
||||||
this.baseURL = baseURL || apiConfig.baseURL;
|
|
||||||
// ✅ CRITICAL FIX: Remove trailing slash
|
|
||||||
this.baseURL = this.baseURL.replace(/\/+$/, '');
|
|
||||||
console.log('🔧 API Client initialized with baseURL:', this.baseURL);
|
|
||||||
}
|
|
||||||
|
|
||||||
private buildURL(endpoint: string): string {
|
|
||||||
// Remove leading slash from endpoint if present to avoid double slashes
|
|
||||||
const cleanEndpoint = endpoint.startsWith('/') ? endpoint : `/${endpoint}`;
|
|
||||||
const fullURL = `${this.baseURL}${cleanEndpoint}`;
|
|
||||||
|
|
||||||
// ✅ DEBUG: Log URL construction
|
|
||||||
console.log('🔗 Building URL:', {
|
|
||||||
baseURL: this.baseURL,
|
|
||||||
endpoint: cleanEndpoint,
|
|
||||||
fullURL: fullURL
|
|
||||||
});
|
|
||||||
|
|
||||||
return fullURL;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Add request interceptor
|
|
||||||
*/
|
|
||||||
addRequestInterceptor(interceptor: RequestInterceptor): void {
|
|
||||||
this.requestInterceptors.push(interceptor);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Add response interceptor
|
|
||||||
*/
|
|
||||||
addResponseInterceptor(interceptor: ResponseInterceptor): void {
|
|
||||||
this.responseInterceptors.push(interceptor);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate cache key for request
|
|
||||||
*/
|
|
||||||
private getCacheKey(url: string, config?: RequestConfig): string {
|
|
||||||
const method = config?.method || 'GET';
|
|
||||||
const params = config?.params ? JSON.stringify(config.params) : '';
|
|
||||||
return `${method}:${url}:${params}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if response is cached and valid
|
|
||||||
*/
|
|
||||||
private getCachedResponse<T>(key: string): T | null {
|
|
||||||
if (!featureFlags.enableRequestDeduplication && !apiConfig.enableCaching) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const cached = this.cache.get(key);
|
|
||||||
if (!cached) return null;
|
|
||||||
|
|
||||||
const now = Date.now();
|
|
||||||
if (now - cached.timestamp > cached.ttl) {
|
|
||||||
this.cache.delete(key);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return cached.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Cache response data
|
|
||||||
*/
|
|
||||||
private setCachedResponse<T>(key: string, data: T, ttl?: number): void {
|
|
||||||
if (!apiConfig.enableCaching) return;
|
|
||||||
|
|
||||||
const cacheTTL = ttl || cacheConfig.defaultTTL;
|
|
||||||
this.cache.set(key, {
|
|
||||||
data,
|
|
||||||
timestamp: Date.now(),
|
|
||||||
ttl: cacheTTL,
|
|
||||||
key,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Cleanup old cache entries if cache is full
|
|
||||||
if (this.cache.size > cacheConfig.maxSize) {
|
|
||||||
const oldestKey = this.cache.keys().next().value;
|
|
||||||
this.cache.delete(oldestKey);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Apply request interceptors
|
|
||||||
*/
|
|
||||||
private async applyRequestInterceptors(config: RequestConfig): Promise<RequestConfig> {
|
|
||||||
let modifiedConfig = { ...config };
|
|
||||||
|
|
||||||
for (const interceptor of this.requestInterceptors) {
|
|
||||||
if (interceptor.onRequest) {
|
|
||||||
try {
|
|
||||||
modifiedConfig = await interceptor.onRequest(modifiedConfig);
|
|
||||||
} catch (error) {
|
|
||||||
if (interceptor.onRequestError) {
|
|
||||||
await interceptor.onRequestError(error);
|
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return modifiedConfig;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Apply response interceptors
|
|
||||||
*/
|
|
||||||
private async applyResponseInterceptors<T>(response: ApiResponse<T>): Promise<ApiResponse<T>> {
|
|
||||||
let modifiedResponse = { ...response };
|
|
||||||
|
|
||||||
for (const interceptor of this.responseInterceptors) {
|
|
||||||
if (interceptor.onResponse) {
|
|
||||||
try {
|
|
||||||
modifiedResponse = await interceptor.onResponse(modifiedResponse);
|
|
||||||
} catch (error) {
|
|
||||||
if (interceptor.onResponseError) {
|
|
||||||
await interceptor.onResponseError(error);
|
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return modifiedResponse;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Retry failed requests with exponential backoff
|
|
||||||
*/
|
|
||||||
private async retryRequest<T>(
|
|
||||||
requestFn: () => Promise<T>,
|
|
||||||
attempts: number = retryConfig.attempts
|
|
||||||
): Promise<T> {
|
|
||||||
try {
|
|
||||||
return await requestFn();
|
|
||||||
} catch (error) {
|
|
||||||
if (attempts <= 0 || !retryConfig.retryCondition(error)) {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
const delay = retryConfig.delay * Math.pow(retryConfig.backoff, retryConfig.attempts - attempts);
|
|
||||||
await new Promise(resolve => setTimeout(resolve, delay));
|
|
||||||
|
|
||||||
return this.retryRequest(requestFn, attempts - 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Record request metrics
|
|
||||||
*/
|
|
||||||
private recordMetrics(metrics: Partial<RequestMetrics>): void {
|
|
||||||
if (!featureFlags.enableMetrics) return;
|
|
||||||
|
|
||||||
const completeMetrics: RequestMetrics = {
|
|
||||||
url: '',
|
|
||||||
method: 'GET',
|
|
||||||
duration: 0,
|
|
||||||
status: 0,
|
|
||||||
size: 0,
|
|
||||||
timestamp: Date.now(),
|
|
||||||
cached: false,
|
|
||||||
retries: 0,
|
|
||||||
...metrics,
|
|
||||||
};
|
|
||||||
|
|
||||||
this.metrics.push(completeMetrics);
|
|
||||||
|
|
||||||
// Keep only recent metrics (last 1000 requests)
|
|
||||||
if (this.metrics.length > 1000) {
|
|
||||||
this.metrics = this.metrics.slice(-1000);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Core request method with all features
|
|
||||||
*/
|
|
||||||
async request<T = any>(endpoint: string, config: RequestConfig = {}): Promise<T> {
|
|
||||||
const startTime = Date.now();
|
|
||||||
const url = this.buildURL(endpoint);
|
|
||||||
const method = config.method || 'GET';
|
|
||||||
|
|
||||||
console.log('🚀 Making API request:', {
|
|
||||||
method,
|
|
||||||
endpoint,
|
|
||||||
url,
|
|
||||||
config
|
|
||||||
});
|
|
||||||
|
|
||||||
// Apply request interceptors
|
|
||||||
const modifiedConfig = await this.applyRequestInterceptors(config);
|
|
||||||
|
|
||||||
// Generate cache key
|
|
||||||
const cacheKey = this.getCacheKey(endpoint, modifiedConfig);
|
|
||||||
|
|
||||||
// Check cache for GET requests
|
|
||||||
if (method === 'GET' && (config.cache !== false)) {
|
|
||||||
const cached = this.getCachedResponse<T>(cacheKey);
|
|
||||||
if (cached) {
|
|
||||||
this.recordMetrics({
|
|
||||||
url: endpoint,
|
|
||||||
method,
|
|
||||||
duration: Date.now() - startTime,
|
|
||||||
status: 200,
|
|
||||||
cached: true,
|
|
||||||
});
|
|
||||||
return cached;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Request deduplication for concurrent requests
|
|
||||||
if (featureFlags.enableRequestDeduplication && method === 'GET') {
|
|
||||||
const pendingRequest = this.pendingRequests.get(cacheKey);
|
|
||||||
if (pendingRequest) {
|
|
||||||
return pendingRequest;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create request promise
|
|
||||||
const requestPromise = this.retryRequest(async () => {
|
|
||||||
const headers: Record<string, string> = {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
...modifiedConfig.headers,
|
|
||||||
};
|
|
||||||
|
|
||||||
const fetchConfig: RequestInit = {
|
|
||||||
method,
|
|
||||||
headers,
|
|
||||||
signal: AbortSignal.timeout(modifiedConfig.timeout || apiConfig.timeout),
|
|
||||||
};
|
|
||||||
|
|
||||||
// Add body for non-GET requests
|
|
||||||
if (method !== 'GET' && modifiedConfig.body) {
|
|
||||||
if (modifiedConfig.body instanceof FormData) {
|
|
||||||
// Remove Content-Type for FormData (let browser set it with boundary)
|
|
||||||
delete headers['Content-Type'];
|
|
||||||
fetchConfig.body = modifiedConfig.body;
|
|
||||||
} else {
|
|
||||||
fetchConfig.body = typeof modifiedConfig.body === 'string'
|
|
||||||
? modifiedConfig.body
|
|
||||||
: JSON.stringify(modifiedConfig.body);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add query parameters
|
|
||||||
const urlWithParams = new URL(url);
|
|
||||||
if (modifiedConfig.params) {
|
|
||||||
Object.entries(modifiedConfig.params).forEach(([key, value]) => {
|
|
||||||
if (value !== undefined && value !== null) {
|
|
||||||
urlWithParams.searchParams.append(key, String(value));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await fetch(urlWithParams.toString(), fetchConfig);
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorText = await response.text();
|
|
||||||
let errorData: ApiError;
|
|
||||||
|
|
||||||
try {
|
|
||||||
errorData = JSON.parse(errorText);
|
|
||||||
} catch {
|
|
||||||
errorData = {
|
|
||||||
message: `HTTP ${response.status}: ${response.statusText}`,
|
|
||||||
detail: errorText,
|
|
||||||
code: `HTTP_${response.status}`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const error = new Error(errorData.message || 'Request failed');
|
|
||||||
(error as any).response = { status: response.status, data: errorData };
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
const responseData = await response.json();
|
|
||||||
console.log('🔍 Raw responseData from fetch:', responseData);
|
|
||||||
|
|
||||||
// Apply response interceptors
|
|
||||||
const processedResponse = await this.applyResponseInterceptors(responseData);
|
|
||||||
console.log('🔍 processedResponse after interceptors:', processedResponse);
|
|
||||||
|
|
||||||
return processedResponse;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Store pending request for deduplication
|
|
||||||
if (featureFlags.enableRequestDeduplication && method === 'GET') {
|
|
||||||
this.pendingRequests.set(cacheKey, requestPromise);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await requestPromise;
|
|
||||||
|
|
||||||
// Cache successful GET responses
|
|
||||||
if (method === 'GET' && config.cache !== false) {
|
|
||||||
this.setCachedResponse(cacheKey, result, config.cacheTTL);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Record metrics
|
|
||||||
this.recordMetrics({
|
|
||||||
url: endpoint,
|
|
||||||
method,
|
|
||||||
duration: Date.now() - startTime,
|
|
||||||
status: 200,
|
|
||||||
size: JSON.stringify(result).length,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Handle both wrapped and unwrapped responses
|
|
||||||
// If result has a 'data' property, return it; otherwise return the result itself
|
|
||||||
console.log('🔍 Final result before return:', result);
|
|
||||||
console.log('🔍 Result has data property?', result && typeof result === 'object' && 'data' in result);
|
|
||||||
|
|
||||||
if (result && typeof result === 'object' && 'data' in result) {
|
|
||||||
console.log('🔍 Returning result.data:', result.data);
|
|
||||||
return result.data as T;
|
|
||||||
}
|
|
||||||
console.log('🔍 Returning raw result:', result);
|
|
||||||
return result as T;
|
|
||||||
} catch (error) {
|
|
||||||
// Record error metrics
|
|
||||||
this.recordMetrics({
|
|
||||||
url: endpoint,
|
|
||||||
method,
|
|
||||||
duration: Date.now() - startTime,
|
|
||||||
status: (error as any).response?.status || 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
throw error;
|
|
||||||
} finally {
|
|
||||||
// Clean up pending request
|
|
||||||
if (featureFlags.enableRequestDeduplication && method === 'GET') {
|
|
||||||
this.pendingRequests.delete(cacheKey);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Convenience methods for HTTP verbs
|
|
||||||
*/
|
|
||||||
async get<T = any>(endpoint: string, config?: RequestConfig): Promise<T> {
|
|
||||||
return this.request<T>(endpoint, { ...config, method: 'GET' });
|
|
||||||
}
|
|
||||||
|
|
||||||
async post<T = any>(endpoint: string, data?: any, config?: RequestConfig): Promise<T> {
|
|
||||||
return this.request<T>(endpoint, {
|
|
||||||
...config,
|
|
||||||
method: 'POST',
|
|
||||||
body: data
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async put<T = any>(endpoint: string, data?: any, config?: RequestConfig): Promise<T> {
|
|
||||||
return this.request<T>(endpoint, {
|
|
||||||
...config,
|
|
||||||
method: 'PUT',
|
|
||||||
body: data
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async patch<T = any>(endpoint: string, data?: any, config?: RequestConfig): Promise<T> {
|
|
||||||
return this.request<T>(endpoint, {
|
|
||||||
...config,
|
|
||||||
method: 'PATCH',
|
|
||||||
body: data
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async delete<T = any>(endpoint: string, config?: RequestConfig): Promise<T> {
|
|
||||||
return this.request<T>(endpoint, { ...config, method: 'DELETE' });
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Raw request that returns the Response object for binary data
|
|
||||||
*/
|
|
||||||
async getRaw(endpoint: string, config?: RequestConfig): Promise<Response> {
|
|
||||||
const url = this.buildURL(endpoint);
|
|
||||||
const modifiedConfig = await this.applyRequestInterceptors(config || {});
|
|
||||||
|
|
||||||
const headers: Record<string, string> = {
|
|
||||||
...modifiedConfig.headers,
|
|
||||||
};
|
|
||||||
|
|
||||||
const fetchConfig: RequestInit = {
|
|
||||||
method: 'GET',
|
|
||||||
headers,
|
|
||||||
signal: AbortSignal.timeout(modifiedConfig.timeout || apiConfig.timeout),
|
|
||||||
};
|
|
||||||
|
|
||||||
// Add query parameters
|
|
||||||
const urlWithParams = new URL(url);
|
|
||||||
if (modifiedConfig.params) {
|
|
||||||
Object.entries(modifiedConfig.params).forEach(([key, value]) => {
|
|
||||||
if (value !== undefined && value !== null) {
|
|
||||||
urlWithParams.searchParams.append(key, String(value));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await fetch(urlWithParams.toString(), fetchConfig);
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorText = await response.text();
|
|
||||||
let errorData: ApiError;
|
|
||||||
|
|
||||||
try {
|
|
||||||
errorData = JSON.parse(errorText);
|
|
||||||
} catch {
|
|
||||||
errorData = {
|
|
||||||
message: `HTTP ${response.status}: ${response.statusText}`,
|
|
||||||
detail: errorText,
|
|
||||||
code: `HTTP_${response.status}`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const error = new Error(errorData.message || 'Request failed');
|
|
||||||
(error as any).response = { status: response.status, data: errorData };
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
return response;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* File upload with progress tracking
|
|
||||||
*/
|
|
||||||
async upload<T = any>(
|
|
||||||
endpoint: string,
|
|
||||||
file: File,
|
|
||||||
additionalData?: Record<string, any>,
|
|
||||||
config?: UploadConfig
|
|
||||||
): Promise<T> {
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append('file', file);
|
|
||||||
|
|
||||||
if (additionalData) {
|
|
||||||
Object.entries(additionalData).forEach(([key, value]) => {
|
|
||||||
formData.append(key, String(value));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// For file uploads, we need to use XMLHttpRequest for progress tracking
|
|
||||||
if (config?.onProgress) {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const xhr = new XMLHttpRequest();
|
|
||||||
|
|
||||||
xhr.upload.addEventListener('progress', (event) => {
|
|
||||||
if (event.lengthComputable && config.onProgress) {
|
|
||||||
const progress: UploadProgress = {
|
|
||||||
loaded: event.loaded,
|
|
||||||
total: event.total,
|
|
||||||
percentage: Math.round((event.loaded / event.total) * 100),
|
|
||||||
};
|
|
||||||
config.onProgress(progress);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
xhr.addEventListener('load', () => {
|
|
||||||
if (xhr.status >= 200 && xhr.status < 300) {
|
|
||||||
try {
|
|
||||||
const result = JSON.parse(xhr.responseText);
|
|
||||||
resolve(result);
|
|
||||||
} catch {
|
|
||||||
resolve(xhr.responseText as any);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
reject(new Error(`Upload failed: ${xhr.status} ${xhr.statusText}`));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
xhr.addEventListener('error', () => {
|
|
||||||
reject(new Error('Upload failed'));
|
|
||||||
});
|
|
||||||
|
|
||||||
xhr.open('POST', `${this.baseURL}${endpoint}`);
|
|
||||||
|
|
||||||
// Add headers (excluding Content-Type for FormData)
|
|
||||||
if (config?.headers) {
|
|
||||||
Object.entries(config.headers).forEach(([key, value]) => {
|
|
||||||
if (key.toLowerCase() !== 'content-type') {
|
|
||||||
xhr.setRequestHeader(key, value);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
xhr.send(formData);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback to regular request for uploads without progress
|
|
||||||
return this.request<T>(endpoint, {
|
|
||||||
...config,
|
|
||||||
method: 'POST',
|
|
||||||
body: formData,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Clear cache
|
|
||||||
*/
|
|
||||||
clearCache(pattern?: string): void {
|
|
||||||
if (pattern) {
|
|
||||||
// Clear cache entries matching pattern
|
|
||||||
const regex = new RegExp(pattern);
|
|
||||||
Array.from(this.cache.keys())
|
|
||||||
.filter(key => regex.test(key))
|
|
||||||
.forEach(key => this.cache.delete(key));
|
|
||||||
} else {
|
|
||||||
// Clear all cache
|
|
||||||
this.cache.clear();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get client metrics
|
|
||||||
*/
|
|
||||||
getMetrics() {
|
|
||||||
if (!featureFlags.enableMetrics) {
|
|
||||||
return {
|
|
||||||
totalRequests: 0,
|
|
||||||
successfulRequests: 0,
|
|
||||||
failedRequests: 0,
|
|
||||||
averageResponseTime: 0,
|
|
||||||
cacheHitRate: 0,
|
|
||||||
errorRate: 0,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const total = this.metrics.length;
|
|
||||||
const successful = this.metrics.filter(m => m.status >= 200 && m.status < 300).length;
|
|
||||||
const cached = this.metrics.filter(m => m.cached).length;
|
|
||||||
const averageTime = total > 0
|
|
||||||
? this.metrics.reduce((sum, m) => sum + m.duration, 0) / total
|
|
||||||
: 0;
|
|
||||||
|
|
||||||
return {
|
|
||||||
totalRequests: total,
|
|
||||||
successfulRequests: successful,
|
|
||||||
failedRequests: total - successful,
|
|
||||||
averageResponseTime: Math.round(averageTime),
|
|
||||||
cacheHitRate: total > 0 ? Math.round((cached / total) * 100) : 0,
|
|
||||||
errorRate: total > 0 ? Math.round(((total - successful) / total) * 100) : 0,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Default API client instance
|
|
||||||
console.log('🔧 Creating default API client...');
|
|
||||||
export const apiClient = new ApiClient();
|
|
||||||
@@ -1,488 +0,0 @@
|
|||||||
// frontend/src/api/client/interceptors.ts
|
|
||||||
/**
|
|
||||||
* Request and Response Interceptors
|
|
||||||
* Handles authentication, logging, error handling, etc.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { apiClient } from './index';
|
|
||||||
import type { RequestConfig, ApiResponse } from './types';
|
|
||||||
import { ApiErrorHandler } from '../utils';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Authentication Interceptor
|
|
||||||
* Automatically adds authentication headers to requests
|
|
||||||
*/
|
|
||||||
class AuthInterceptor {
|
|
||||||
static isTokenExpired(token: string): boolean {
|
|
||||||
try {
|
|
||||||
const payload = JSON.parse(atob(token.split('.')[1]));
|
|
||||||
const currentTime = Math.floor(Date.now() / 1000);
|
|
||||||
return payload.exp <= currentTime;
|
|
||||||
} catch (error) {
|
|
||||||
console.warn('Error parsing token:', error);
|
|
||||||
return true; // Treat invalid tokens as expired
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static isTokenExpiringSoon(token: string, bufferMinutes: number = 5): boolean {
|
|
||||||
try {
|
|
||||||
const payload = JSON.parse(atob(token.split('.')[1]));
|
|
||||||
const currentTime = Math.floor(Date.now() / 1000);
|
|
||||||
const bufferSeconds = bufferMinutes * 60;
|
|
||||||
return payload.exp <= (currentTime + bufferSeconds);
|
|
||||||
} catch (error) {
|
|
||||||
console.warn('Error parsing token for expiration check:', error);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static async refreshTokenIfNeeded(): Promise<void> {
|
|
||||||
const token = localStorage.getItem('auth_token');
|
|
||||||
const refreshToken = localStorage.getItem('refresh_token');
|
|
||||||
|
|
||||||
if (!token || !refreshToken) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If token is expiring within 5 minutes, proactively refresh
|
|
||||||
if (this.isTokenExpiringSoon(token, 5)) {
|
|
||||||
try {
|
|
||||||
const baseURL = (apiClient as any).baseURL || window.location.origin;
|
|
||||||
const response = await fetch(`${baseURL}/api/v1/auth/refresh`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ refresh_token: refreshToken }),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
const data = await response.json();
|
|
||||||
localStorage.setItem('auth_token', data.access_token);
|
|
||||||
if (data.refresh_token) {
|
|
||||||
localStorage.setItem('refresh_token', data.refresh_token);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
console.warn('Token refresh failed:', response.status);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.warn('Token refresh error:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static setup() {
|
|
||||||
apiClient.addRequestInterceptor({
|
|
||||||
onRequest: async (config: RequestConfig) => {
|
|
||||||
// Proactively refresh token if needed
|
|
||||||
await this.refreshTokenIfNeeded();
|
|
||||||
|
|
||||||
let token = localStorage.getItem('auth_token');
|
|
||||||
|
|
||||||
if (token) {
|
|
||||||
// Check if token is expired
|
|
||||||
if (this.isTokenExpired(token)) {
|
|
||||||
console.warn('Token expired, removing from storage');
|
|
||||||
localStorage.removeItem('auth_token');
|
|
||||||
token = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (token) {
|
|
||||||
config.headers = {
|
|
||||||
...config.headers,
|
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
console.warn('No valid auth token found - authentication required');
|
|
||||||
}
|
|
||||||
|
|
||||||
return config;
|
|
||||||
},
|
|
||||||
|
|
||||||
onRequestError: async (error: any) => {
|
|
||||||
console.error('Request interceptor error:', error);
|
|
||||||
throw error;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Logging Interceptor
|
|
||||||
* Logs API requests and responses for debugging
|
|
||||||
*/
|
|
||||||
class LoggingInterceptor {
|
|
||||||
static setup() {
|
|
||||||
apiClient.addRequestInterceptor({
|
|
||||||
onRequest: async (config: RequestConfig) => {
|
|
||||||
const requestId = `req_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
|
||||||
|
|
||||||
console.group(`🚀 API Request [${requestId}]`);
|
|
||||||
console.log('Method:', config.method);
|
|
||||||
console.log('URL:', config.url);
|
|
||||||
console.log('Headers:', config.headers);
|
|
||||||
if (config.body && config.method !== 'GET') {
|
|
||||||
console.log('Body:', config.body);
|
|
||||||
}
|
|
||||||
if (config.params) {
|
|
||||||
console.log('Params:', config.params);
|
|
||||||
}
|
|
||||||
console.groupEnd();
|
|
||||||
|
|
||||||
// Add request ID to config for response correlation
|
|
||||||
config.headers = {
|
|
||||||
...config.headers,
|
|
||||||
'X-Request-ID': requestId,
|
|
||||||
};
|
|
||||||
|
|
||||||
return config;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
apiClient.addResponseInterceptor({
|
|
||||||
onResponse: async <T>(response: ApiResponse<T>) => {
|
|
||||||
const requestId = response.meta?.requestId || 'unknown';
|
|
||||||
|
|
||||||
console.group(`✅ API Response [${requestId}]`);
|
|
||||||
console.log('Status:', response.status);
|
|
||||||
console.log('Data:', response.data);
|
|
||||||
if (response.message) {
|
|
||||||
console.log('Message:', response.message);
|
|
||||||
}
|
|
||||||
console.groupEnd();
|
|
||||||
|
|
||||||
return response;
|
|
||||||
},
|
|
||||||
|
|
||||||
onResponseError: async (error: any) => {
|
|
||||||
const requestId = error?.config?.headers?.[`X-Request-ID`] || 'unknown';
|
|
||||||
|
|
||||||
console.group(`❌ API Error [${requestId}]`);
|
|
||||||
console.error('Status:', error?.response?.status);
|
|
||||||
console.error('Error:', ApiErrorHandler.formatError(error));
|
|
||||||
console.error('Full Error:', error);
|
|
||||||
console.groupEnd();
|
|
||||||
|
|
||||||
throw error;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Tenant Context Interceptor
|
|
||||||
* Automatically adds tenant context to tenant-scoped requests
|
|
||||||
*/
|
|
||||||
class TenantInterceptor {
|
|
||||||
private static currentTenantId: string | null = null;
|
|
||||||
|
|
||||||
static setCurrentTenant(tenantId: string | null) {
|
|
||||||
this.currentTenantId = tenantId;
|
|
||||||
}
|
|
||||||
|
|
||||||
static getCurrentTenant(): string | null {
|
|
||||||
return this.currentTenantId;
|
|
||||||
}
|
|
||||||
|
|
||||||
static setup() {
|
|
||||||
apiClient.addRequestInterceptor({
|
|
||||||
onRequest: async (config: RequestConfig) => {
|
|
||||||
// Add tenant context to tenant-scoped endpoints
|
|
||||||
if (this.currentTenantId && this.isTenantScopedEndpoint(config.url)) {
|
|
||||||
config.headers = {
|
|
||||||
...config.headers,
|
|
||||||
'X-Tenant-ID': this.currentTenantId,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return config;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private static isTenantScopedEndpoint(url?: string): boolean {
|
|
||||||
if (!url) return false;
|
|
||||||
return url.includes('/tenants/') ||
|
|
||||||
url.includes('/training/') ||
|
|
||||||
url.includes('/forecasts/') ||
|
|
||||||
url.includes('/notifications/');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Error Recovery Interceptor
|
|
||||||
* Handles automatic token refresh and retry logic
|
|
||||||
*/
|
|
||||||
class ErrorRecoveryInterceptor {
|
|
||||||
private static isRefreshing = false;
|
|
||||||
private static failedQueue: Array<{
|
|
||||||
resolve: (token: string) => void;
|
|
||||||
reject: (error: any) => void;
|
|
||||||
}> = [];
|
|
||||||
|
|
||||||
static setup() {
|
|
||||||
apiClient.addResponseInterceptor({
|
|
||||||
onResponseError: async (error: any) => {
|
|
||||||
const originalRequest = error.config;
|
|
||||||
|
|
||||||
// Handle 401 errors with token refresh
|
|
||||||
if (error?.response?.status === 401 && !originalRequest._retry) {
|
|
||||||
if (this.isRefreshing) {
|
|
||||||
// Queue the request while refresh is in progress
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
this.failedQueue.push({ resolve, reject });
|
|
||||||
}).then(token => {
|
|
||||||
return this.retryRequestWithNewToken(originalRequest, token as string);
|
|
||||||
}).catch(err => {
|
|
||||||
throw err;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
originalRequest._retry = true;
|
|
||||||
this.isRefreshing = true;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const refreshToken = localStorage.getItem('refresh_token');
|
|
||||||
|
|
||||||
if (!refreshToken) {
|
|
||||||
throw new Error('No refresh token available');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use direct fetch to avoid interceptor recursion
|
|
||||||
const baseURL = (apiClient as any).baseURL || window.location.origin;
|
|
||||||
const response = await fetch(`${baseURL}/api/v1/auth/refresh`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ refresh_token: refreshToken }),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`Token refresh failed: ${response.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
const newToken = data.access_token;
|
|
||||||
|
|
||||||
if (!newToken) {
|
|
||||||
throw new Error('No access token received');
|
|
||||||
}
|
|
||||||
|
|
||||||
localStorage.setItem('auth_token', newToken);
|
|
||||||
|
|
||||||
// Update new refresh token if provided
|
|
||||||
if (data.refresh_token) {
|
|
||||||
localStorage.setItem('refresh_token', data.refresh_token);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Process failed queue
|
|
||||||
this.processQueue(null, newToken);
|
|
||||||
|
|
||||||
// Retry original request with new token
|
|
||||||
return this.retryRequestWithNewToken(originalRequest, newToken);
|
|
||||||
|
|
||||||
} catch (refreshError) {
|
|
||||||
console.warn('Token refresh failed:', refreshError);
|
|
||||||
this.processQueue(refreshError, null);
|
|
||||||
|
|
||||||
// Clear auth data and redirect to login
|
|
||||||
localStorage.removeItem('auth_token');
|
|
||||||
localStorage.removeItem('refresh_token');
|
|
||||||
localStorage.removeItem('user_data');
|
|
||||||
|
|
||||||
// Only redirect if we're not already on the login page
|
|
||||||
if (typeof window !== 'undefined' && !window.location.pathname.includes('/login')) {
|
|
||||||
window.location.href = '/login';
|
|
||||||
}
|
|
||||||
|
|
||||||
throw refreshError;
|
|
||||||
} finally {
|
|
||||||
this.isRefreshing = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
throw error;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async retryRequestWithNewToken(originalRequest: any, token: string) {
|
|
||||||
try {
|
|
||||||
// Use direct fetch instead of apiClient to avoid interceptor recursion
|
|
||||||
const url = originalRequest.url || originalRequest.endpoint;
|
|
||||||
const method = originalRequest.method || 'GET';
|
|
||||||
|
|
||||||
const fetchOptions: RequestInit = {
|
|
||||||
method,
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'Authorization': `Bearer ${token}`,
|
|
||||||
...originalRequest.headers
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Add body for non-GET requests
|
|
||||||
if (method !== 'GET' && originalRequest.body) {
|
|
||||||
fetchOptions.body = typeof originalRequest.body === 'string'
|
|
||||||
? originalRequest.body
|
|
||||||
: JSON.stringify(originalRequest.body);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add query parameters if present
|
|
||||||
let fullUrl = url;
|
|
||||||
if (originalRequest.params) {
|
|
||||||
const urlWithParams = new URL(fullUrl, (apiClient as any).baseURL);
|
|
||||||
Object.entries(originalRequest.params).forEach(([key, value]) => {
|
|
||||||
if (value !== undefined && value !== null) {
|
|
||||||
urlWithParams.searchParams.append(key, String(value));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
fullUrl = urlWithParams.toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Retry request with refreshed token
|
|
||||||
|
|
||||||
const response = await fetch(fullUrl, fetchOptions);
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`Request failed: ${response.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return await response.json();
|
|
||||||
} catch (retryError) {
|
|
||||||
console.warn('Request retry failed:', retryError);
|
|
||||||
throw retryError;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static processQueue(error: any, token: string | null) {
|
|
||||||
this.failedQueue.forEach(({ resolve, reject }) => {
|
|
||||||
if (error) {
|
|
||||||
reject(error);
|
|
||||||
} else {
|
|
||||||
resolve(token!);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
this.failedQueue = [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Performance Monitoring Interceptor
|
|
||||||
* Tracks API performance metrics
|
|
||||||
*/
|
|
||||||
class PerformanceInterceptor {
|
|
||||||
private static metrics: Array<{
|
|
||||||
url: string;
|
|
||||||
method: string;
|
|
||||||
duration: number;
|
|
||||||
status: number;
|
|
||||||
timestamp: number;
|
|
||||||
}> = [];
|
|
||||||
|
|
||||||
static setup() {
|
|
||||||
apiClient.addRequestInterceptor({
|
|
||||||
onRequest: async (config: RequestConfig) => {
|
|
||||||
config.metadata = {
|
|
||||||
...config.metadata,
|
|
||||||
startTime: Date.now(),
|
|
||||||
};
|
|
||||||
|
|
||||||
return config;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
apiClient.addResponseInterceptor({
|
|
||||||
onResponse: async <T>(response: ApiResponse<T>) => {
|
|
||||||
const startTime = response.metadata?.startTime;
|
|
||||||
if (startTime) {
|
|
||||||
const duration = Date.now() - startTime;
|
|
||||||
this.recordMetric({
|
|
||||||
url: response.metadata?.url || 'unknown',
|
|
||||||
method: response.metadata?.method || 'unknown',
|
|
||||||
duration,
|
|
||||||
status: 200,
|
|
||||||
timestamp: Date.now(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return response;
|
|
||||||
},
|
|
||||||
|
|
||||||
onResponseError: async (error: any) => {
|
|
||||||
const startTime = error.config?.metadata?.startTime;
|
|
||||||
if (startTime) {
|
|
||||||
const duration = Date.now() - startTime;
|
|
||||||
this.recordMetric({
|
|
||||||
url: error.config?.url || 'unknown',
|
|
||||||
method: error.config?.method || 'unknown',
|
|
||||||
duration,
|
|
||||||
status: error?.response?.status || 0,
|
|
||||||
timestamp: Date.now(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
throw error;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private static recordMetric(metric: any) {
|
|
||||||
this.metrics.push(metric);
|
|
||||||
|
|
||||||
// Keep only last 1000 metrics
|
|
||||||
if (this.metrics.length > 1000) {
|
|
||||||
this.metrics = this.metrics.slice(-1000);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static getMetrics() {
|
|
||||||
return [...this.metrics];
|
|
||||||
}
|
|
||||||
|
|
||||||
static getAverageResponseTime(): number {
|
|
||||||
if (this.metrics.length === 0) return 0;
|
|
||||||
|
|
||||||
const total = this.metrics.reduce((sum, metric) => sum + metric.duration, 0);
|
|
||||||
return Math.round(total / this.metrics.length);
|
|
||||||
}
|
|
||||||
|
|
||||||
static getErrorRate(): number {
|
|
||||||
if (this.metrics.length === 0) return 0;
|
|
||||||
|
|
||||||
const errorCount = this.metrics.filter(metric => metric.status >= 400).length;
|
|
||||||
return Math.round((errorCount / this.metrics.length) * 100);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Setup all interceptors
|
|
||||||
* IMPORTANT: Order matters! ErrorRecoveryInterceptor must be first to handle token refresh
|
|
||||||
*/
|
|
||||||
export const setupInterceptors = () => {
|
|
||||||
// 1. Error recovery first (handles 401 and token refresh)
|
|
||||||
ErrorRecoveryInterceptor.setup();
|
|
||||||
|
|
||||||
// 2. Authentication (adds Bearer tokens)
|
|
||||||
AuthInterceptor.setup();
|
|
||||||
|
|
||||||
// 3. Tenant context
|
|
||||||
TenantInterceptor.setup();
|
|
||||||
|
|
||||||
// 4. Development-only interceptors
|
|
||||||
const isDevelopment = true; // Temporarily set to true for development
|
|
||||||
if (isDevelopment) {
|
|
||||||
LoggingInterceptor.setup();
|
|
||||||
PerformanceInterceptor.setup();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Export interceptor classes for manual setup if needed
|
|
||||||
export {
|
|
||||||
AuthInterceptor,
|
|
||||||
LoggingInterceptor,
|
|
||||||
TenantInterceptor,
|
|
||||||
ErrorRecoveryInterceptor,
|
|
||||||
PerformanceInterceptor,
|
|
||||||
};
|
|
||||||
@@ -1,115 +0,0 @@
|
|||||||
// frontend/src/api/client/types.ts
|
|
||||||
/**
|
|
||||||
* Core API Client Types
|
|
||||||
*/
|
|
||||||
|
|
||||||
export interface RequestConfig {
|
|
||||||
method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
|
|
||||||
headers?: Record<string, string>;
|
|
||||||
params?: Record<string, any>;
|
|
||||||
body?: any;
|
|
||||||
url?: string;
|
|
||||||
timeout?: number;
|
|
||||||
retries?: number;
|
|
||||||
cache?: boolean;
|
|
||||||
cacheTTL?: number;
|
|
||||||
optimistic?: boolean;
|
|
||||||
background?: boolean;
|
|
||||||
metadata?: any;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ApiResponse<T = any> {
|
|
||||||
data: T;
|
|
||||||
message?: string;
|
|
||||||
status: string;
|
|
||||||
timestamp?: string;
|
|
||||||
metadata?: any;
|
|
||||||
meta?: {
|
|
||||||
page?: number;
|
|
||||||
limit?: number;
|
|
||||||
total?: number;
|
|
||||||
hasNext?: boolean;
|
|
||||||
hasPrev?: boolean;
|
|
||||||
requestId?: string;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ApiError {
|
|
||||||
message: string;
|
|
||||||
detail?: string;
|
|
||||||
code?: string;
|
|
||||||
field?: string;
|
|
||||||
timestamp?: string;
|
|
||||||
service?: string;
|
|
||||||
requestId?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PaginatedResponse<T> {
|
|
||||||
data: T[];
|
|
||||||
pagination: {
|
|
||||||
page: number;
|
|
||||||
limit: number;
|
|
||||||
total: number;
|
|
||||||
totalPages: number;
|
|
||||||
hasNext: boolean;
|
|
||||||
hasPrev: boolean;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UploadProgress {
|
|
||||||
loaded: number;
|
|
||||||
total: number;
|
|
||||||
percentage: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UploadConfig extends RequestConfig {
|
|
||||||
onProgress?: (progress: UploadProgress) => void;
|
|
||||||
maxFileSize?: number;
|
|
||||||
allowedTypes?: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Request/Response interceptor types
|
|
||||||
export interface RequestInterceptor {
|
|
||||||
onRequest?: (config: RequestConfig) => RequestConfig | Promise<RequestConfig>;
|
|
||||||
onRequestError?: (error: any) => any;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ResponseInterceptor {
|
|
||||||
onResponse?: <T>(response: ApiResponse<T>) => ApiResponse<T> | Promise<ApiResponse<T>>;
|
|
||||||
onResponseError?: (error: any) => any;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cache types
|
|
||||||
export interface CacheEntry<T = any> {
|
|
||||||
data: T;
|
|
||||||
timestamp: number;
|
|
||||||
ttl: number;
|
|
||||||
key: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CacheStrategy {
|
|
||||||
key: (url: string, params?: any) => string;
|
|
||||||
ttl: number;
|
|
||||||
enabled: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Metrics types
|
|
||||||
export interface RequestMetrics {
|
|
||||||
url: string;
|
|
||||||
method: string;
|
|
||||||
duration: number;
|
|
||||||
status: number;
|
|
||||||
size: number;
|
|
||||||
timestamp: number;
|
|
||||||
cached: boolean;
|
|
||||||
retries: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ClientMetrics {
|
|
||||||
totalRequests: number;
|
|
||||||
successfulRequests: number;
|
|
||||||
failedRequests: number;
|
|
||||||
averageResponseTime: number;
|
|
||||||
cacheHitRate: number;
|
|
||||||
errorRate: number;
|
|
||||||
}
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
// frontend/src/api/hooks/index.ts
|
|
||||||
/**
|
|
||||||
* Main Hooks Export
|
|
||||||
*/
|
|
||||||
|
|
||||||
export { useAuth, useAuthHeaders } from './useAuth';
|
|
||||||
export { useTenant } from './useTenant';
|
|
||||||
export { useSales } from './useSales';
|
|
||||||
export { useExternal } from './useExternal';
|
|
||||||
export { useTraining } from './useTraining';
|
|
||||||
export { useForecast } from './useForecast';
|
|
||||||
export { useNotification } from './useNotification';
|
|
||||||
export { useOnboarding, useOnboardingStep } from './useOnboarding';
|
|
||||||
export { useInventory, useInventoryDashboard, useInventoryItem, useInventoryProducts } from './useInventory';
|
|
||||||
export { useRecipes, useProduction } from './useRecipes';
|
|
||||||
export {
|
|
||||||
useCurrentProcurementPlan,
|
|
||||||
useProcurementPlanByDate,
|
|
||||||
useProcurementPlan,
|
|
||||||
useProcurementPlans,
|
|
||||||
usePlanRequirements,
|
|
||||||
useCriticalRequirements,
|
|
||||||
useProcurementDashboard,
|
|
||||||
useGenerateProcurementPlan,
|
|
||||||
useUpdatePlanStatus,
|
|
||||||
useTriggerDailyScheduler,
|
|
||||||
useProcurementHealth,
|
|
||||||
useProcurementPlanDashboard,
|
|
||||||
useProcurementPlanActions
|
|
||||||
} from './useProcurement';
|
|
||||||
|
|
||||||
// Import hooks for combined usage
|
|
||||||
import { useAuth } from './useAuth';
|
|
||||||
import { useTenant } from './useTenant';
|
|
||||||
import { useSales } from './useSales';
|
|
||||||
import { useExternal } from './useExternal';
|
|
||||||
import { useTraining } from './useTraining';
|
|
||||||
import { useForecast } from './useForecast';
|
|
||||||
import { useNotification } from './useNotification';
|
|
||||||
import { useOnboarding } from './useOnboarding';
|
|
||||||
import { useInventory } from './useInventory';
|
|
||||||
|
|
||||||
// Combined hook for common operations
|
|
||||||
export const useApiHooks = () => {
|
|
||||||
const auth = useAuth();
|
|
||||||
const tenant = useTenant();
|
|
||||||
const sales = useSales();
|
|
||||||
const external = useExternal();
|
|
||||||
const training = useTraining({ disablePolling: true }); // Disable polling by default
|
|
||||||
const forecast = useForecast();
|
|
||||||
const notification = useNotification();
|
|
||||||
const onboarding = useOnboarding();
|
|
||||||
const inventory = useInventory();
|
|
||||||
|
|
||||||
return {
|
|
||||||
auth,
|
|
||||||
tenant,
|
|
||||||
sales,
|
|
||||||
external,
|
|
||||||
training,
|
|
||||||
forecast,
|
|
||||||
notification,
|
|
||||||
onboarding,
|
|
||||||
inventory
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -1,205 +0,0 @@
|
|||||||
// frontend/src/api/hooks/useAuth.ts
|
|
||||||
/**
|
|
||||||
* Authentication Hooks
|
|
||||||
* React hooks for authentication operations
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { useState, useEffect, useCallback } from 'react';
|
|
||||||
import { authService } from '../services';
|
|
||||||
import type {
|
|
||||||
LoginRequest,
|
|
||||||
LoginResponse,
|
|
||||||
RegisterRequest,
|
|
||||||
UserResponse,
|
|
||||||
PasswordResetRequest,
|
|
||||||
} from '../types';
|
|
||||||
|
|
||||||
// Token management
|
|
||||||
const TOKEN_KEY = 'auth_token';
|
|
||||||
const REFRESH_TOKEN_KEY = 'refresh_token';
|
|
||||||
const USER_KEY = 'user_data';
|
|
||||||
|
|
||||||
export const useAuth = () => {
|
|
||||||
const [user, setUser] = useState<UserResponse | null>(null);
|
|
||||||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
// Initialize auth state from localStorage
|
|
||||||
useEffect(() => {
|
|
||||||
const initializeAuth = async () => {
|
|
||||||
try {
|
|
||||||
const token = localStorage.getItem(TOKEN_KEY);
|
|
||||||
const userData = localStorage.getItem(USER_KEY);
|
|
||||||
|
|
||||||
if (token && userData) {
|
|
||||||
setUser(JSON.parse(userData));
|
|
||||||
setIsAuthenticated(true);
|
|
||||||
|
|
||||||
// Verify token is still valid
|
|
||||||
try {
|
|
||||||
const currentUser = await authService.getCurrentUser();
|
|
||||||
setUser(currentUser);
|
|
||||||
} catch (error) {
|
|
||||||
// Token might be expired - let interceptors handle refresh
|
|
||||||
// Only logout if refresh also fails (handled by ErrorRecoveryInterceptor)
|
|
||||||
console.log('Token verification failed, interceptors will handle refresh if possible');
|
|
||||||
|
|
||||||
// Check if we have a refresh token - if not, logout immediately
|
|
||||||
const refreshToken = localStorage.getItem(REFRESH_TOKEN_KEY);
|
|
||||||
if (!refreshToken) {
|
|
||||||
console.log('No refresh token available, logging out');
|
|
||||||
logout();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Auth initialization error:', error);
|
|
||||||
logout();
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
initializeAuth();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const login = useCallback(async (credentials: LoginRequest): Promise<void> => {
|
|
||||||
try {
|
|
||||||
setIsLoading(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
const response = await authService.login(credentials);
|
|
||||||
|
|
||||||
// Store tokens and user data
|
|
||||||
localStorage.setItem(TOKEN_KEY, response.access_token);
|
|
||||||
if (response.refresh_token) {
|
|
||||||
localStorage.setItem(REFRESH_TOKEN_KEY, response.refresh_token);
|
|
||||||
}
|
|
||||||
if (response.user) {
|
|
||||||
localStorage.setItem(USER_KEY, JSON.stringify(response.user));
|
|
||||||
setUser(response.user);
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsAuthenticated(true);
|
|
||||||
} catch (error) {
|
|
||||||
const message = error instanceof Error ? error.message : 'Login failed';
|
|
||||||
setError(message);
|
|
||||||
throw error;
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const register = useCallback(async (data: RegisterRequest): Promise<void> => {
|
|
||||||
try {
|
|
||||||
setIsLoading(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
const response = await authService.register(data);
|
|
||||||
|
|
||||||
// Auto-login after successful registration
|
|
||||||
if (response && response.user) {
|
|
||||||
await login({ email: data.email, password: data.password });
|
|
||||||
} else {
|
|
||||||
// If response doesn't have user property, registration might still be successful
|
|
||||||
// Try to login anyway in case the user was created but response format is different
|
|
||||||
await login({ email: data.email, password: data.password });
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
const message = error instanceof Error ? error.message : 'Registration failed';
|
|
||||||
setError(message);
|
|
||||||
throw error;
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
}, [login]);
|
|
||||||
|
|
||||||
const logout = useCallback(async (): Promise<void> => {
|
|
||||||
try {
|
|
||||||
// Call logout endpoint if authenticated
|
|
||||||
if (isAuthenticated) {
|
|
||||||
await authService.logout();
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Logout error:', error);
|
|
||||||
} finally {
|
|
||||||
// Clear local state regardless of API call success
|
|
||||||
localStorage.removeItem(TOKEN_KEY);
|
|
||||||
localStorage.removeItem(REFRESH_TOKEN_KEY);
|
|
||||||
localStorage.removeItem(USER_KEY);
|
|
||||||
setUser(null);
|
|
||||||
setIsAuthenticated(false);
|
|
||||||
setError(null);
|
|
||||||
}
|
|
||||||
}, [isAuthenticated]);
|
|
||||||
|
|
||||||
const updateProfile = useCallback(async (data: Partial<UserResponse>): Promise<void> => {
|
|
||||||
try {
|
|
||||||
setIsLoading(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
const updatedUser = await authService.updateProfile(data);
|
|
||||||
setUser(updatedUser);
|
|
||||||
localStorage.setItem(USER_KEY, JSON.stringify(updatedUser));
|
|
||||||
} catch (error) {
|
|
||||||
const message = error instanceof Error ? error.message : 'Profile update failed';
|
|
||||||
setError(message);
|
|
||||||
throw error;
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const requestPasswordReset = useCallback(async (data: PasswordResetRequest): Promise<void> => {
|
|
||||||
try {
|
|
||||||
setIsLoading(true);
|
|
||||||
setError(null);
|
|
||||||
await authService.requestPasswordReset(data);
|
|
||||||
} catch (error) {
|
|
||||||
const message = error instanceof Error ? error.message : 'Password reset request failed';
|
|
||||||
setError(message);
|
|
||||||
throw error;
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const changePassword = useCallback(async (currentPassword: string, newPassword: string): Promise<void> => {
|
|
||||||
try {
|
|
||||||
setIsLoading(true);
|
|
||||||
setError(null);
|
|
||||||
await authService.changePassword(currentPassword, newPassword);
|
|
||||||
} catch (error) {
|
|
||||||
const message = error instanceof Error ? error.message : 'Password change failed';
|
|
||||||
setError(message);
|
|
||||||
throw error;
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return {
|
|
||||||
user,
|
|
||||||
isAuthenticated,
|
|
||||||
isLoading,
|
|
||||||
error,
|
|
||||||
login,
|
|
||||||
register,
|
|
||||||
logout,
|
|
||||||
updateProfile,
|
|
||||||
requestPasswordReset,
|
|
||||||
changePassword,
|
|
||||||
clearError: () => setError(null),
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
// Hook for getting authentication headers
|
|
||||||
export const useAuthHeaders = () => {
|
|
||||||
const getAuthHeaders = useCallback(() => {
|
|
||||||
const token = localStorage.getItem(TOKEN_KEY);
|
|
||||||
return token ? { Authorization: `Bearer ${token}` } : {};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return { getAuthHeaders };
|
|
||||||
};
|
|
||||||
@@ -1,238 +0,0 @@
|
|||||||
// frontend/src/api/hooks/useExternal.ts
|
|
||||||
/**
|
|
||||||
* External Data Management Hooks
|
|
||||||
* Handles weather and traffic data operations
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { useState, useCallback } from 'react';
|
|
||||||
import { externalService } from '../services/external.service';
|
|
||||||
import type { WeatherData, TrafficData, WeatherForecast, HourlyForecast } from '../services/external.service';
|
|
||||||
|
|
||||||
export const useExternal = () => {
|
|
||||||
const [weatherData, setWeatherData] = useState<WeatherData | null>(null);
|
|
||||||
const [trafficData, setTrafficData] = useState<TrafficData | null>(null);
|
|
||||||
const [weatherForecast, setWeatherForecast] = useState<WeatherForecast[]>([]);
|
|
||||||
const [hourlyForecast, setHourlyForecast] = useState<HourlyForecast[]>([]);
|
|
||||||
const [trafficForecast, setTrafficForecast] = useState<TrafficData[]>([]);
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get Current Weather
|
|
||||||
*/
|
|
||||||
const getCurrentWeather = useCallback(async (
|
|
||||||
tenantId: string,
|
|
||||||
lat: number,
|
|
||||||
lon: number
|
|
||||||
): Promise<WeatherData> => {
|
|
||||||
try {
|
|
||||||
setIsLoading(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
const weather = await externalService.getCurrentWeather(tenantId, lat, lon);
|
|
||||||
setWeatherData(weather);
|
|
||||||
|
|
||||||
return weather;
|
|
||||||
} catch (error) {
|
|
||||||
const message = error instanceof Error ? error.message : 'Failed to get weather data';
|
|
||||||
setError(message);
|
|
||||||
throw error;
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get Weather Forecast
|
|
||||||
*/
|
|
||||||
const getWeatherForecast = useCallback(async (
|
|
||||||
tenantId: string,
|
|
||||||
lat: number,
|
|
||||||
lon: number,
|
|
||||||
days: number = 7
|
|
||||||
): Promise<WeatherForecast[]> => {
|
|
||||||
try {
|
|
||||||
setIsLoading(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
const forecast = await externalService.getWeatherForecast(tenantId, lat, lon, days);
|
|
||||||
setWeatherForecast(forecast);
|
|
||||||
|
|
||||||
return forecast;
|
|
||||||
} catch (error) {
|
|
||||||
const message = error instanceof Error ? error.message : 'Failed to get weather forecast';
|
|
||||||
setError(message);
|
|
||||||
throw error;
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get Hourly Weather Forecast
|
|
||||||
*/
|
|
||||||
const getHourlyWeatherForecast = useCallback(async (
|
|
||||||
tenantId: string,
|
|
||||||
lat: number,
|
|
||||||
lon: number,
|
|
||||||
hours: number = 48
|
|
||||||
): Promise<HourlyForecast[]> => {
|
|
||||||
try {
|
|
||||||
setIsLoading(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
const forecast = await externalService.getHourlyWeatherForecast(tenantId, lat, lon, hours);
|
|
||||||
setHourlyForecast(forecast);
|
|
||||||
|
|
||||||
return forecast;
|
|
||||||
} catch (error) {
|
|
||||||
const message = error instanceof Error ? error.message : 'Failed to get hourly weather forecast';
|
|
||||||
setError(message);
|
|
||||||
throw error;
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get Historical Weather Data
|
|
||||||
*/
|
|
||||||
const getHistoricalWeather = useCallback(async (
|
|
||||||
tenantId: string,
|
|
||||||
lat: number,
|
|
||||||
lon: number,
|
|
||||||
startDate: string,
|
|
||||||
endDate: string
|
|
||||||
): Promise<WeatherData[]> => {
|
|
||||||
try {
|
|
||||||
setIsLoading(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
const data = await externalService.getHistoricalWeather(tenantId, lat, lon, startDate, endDate);
|
|
||||||
|
|
||||||
return data;
|
|
||||||
} catch (error) {
|
|
||||||
const message = error instanceof Error ? error.message : 'Failed to get historical weather';
|
|
||||||
setError(message);
|
|
||||||
throw error;
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get Current Traffic
|
|
||||||
*/
|
|
||||||
const getCurrentTraffic = useCallback(async (
|
|
||||||
tenantId: string,
|
|
||||||
lat: number,
|
|
||||||
lon: number
|
|
||||||
): Promise<TrafficData> => {
|
|
||||||
try {
|
|
||||||
setIsLoading(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
const traffic = await externalService.getCurrentTraffic(tenantId, lat, lon);
|
|
||||||
setTrafficData(traffic);
|
|
||||||
|
|
||||||
return traffic;
|
|
||||||
} catch (error) {
|
|
||||||
const message = error instanceof Error ? error.message : 'Failed to get traffic data';
|
|
||||||
setError(message);
|
|
||||||
throw error;
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get Traffic Forecast
|
|
||||||
*/
|
|
||||||
const getTrafficForecast = useCallback(async (
|
|
||||||
tenantId: string,
|
|
||||||
lat: number,
|
|
||||||
lon: number,
|
|
||||||
hours: number = 24
|
|
||||||
): Promise<TrafficData[]> => {
|
|
||||||
try {
|
|
||||||
setIsLoading(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
const forecast = await externalService.getTrafficForecast(tenantId, lat, lon, hours);
|
|
||||||
setTrafficForecast(forecast);
|
|
||||||
|
|
||||||
return forecast;
|
|
||||||
} catch (error) {
|
|
||||||
const message = error instanceof Error ? error.message : 'Failed to get traffic forecast';
|
|
||||||
setError(message);
|
|
||||||
throw error;
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get Historical Traffic Data
|
|
||||||
*/
|
|
||||||
const getHistoricalTraffic = useCallback(async (
|
|
||||||
tenantId: string,
|
|
||||||
lat: number,
|
|
||||||
lon: number,
|
|
||||||
startDate: string,
|
|
||||||
endDate: string
|
|
||||||
): Promise<TrafficData[]> => {
|
|
||||||
try {
|
|
||||||
setIsLoading(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
const data = await externalService.getHistoricalTraffic(tenantId, lat, lon, startDate, endDate);
|
|
||||||
|
|
||||||
return data;
|
|
||||||
} catch (error) {
|
|
||||||
const message = error instanceof Error ? error.message : 'Failed to get historical traffic';
|
|
||||||
setError(message);
|
|
||||||
throw error;
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Test External Services Connectivity
|
|
||||||
*/
|
|
||||||
const testConnectivity = useCallback(async (tenantId: string) => {
|
|
||||||
try {
|
|
||||||
setIsLoading(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
const results = await externalService.testConnectivity(tenantId);
|
|
||||||
|
|
||||||
return results;
|
|
||||||
} catch (error) {
|
|
||||||
const message = error instanceof Error ? error.message : 'Failed to test connectivity';
|
|
||||||
setError(message);
|
|
||||||
throw error;
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return {
|
|
||||||
weatherData,
|
|
||||||
trafficData,
|
|
||||||
weatherForecast,
|
|
||||||
hourlyForecast,
|
|
||||||
trafficForecast,
|
|
||||||
isLoading,
|
|
||||||
error,
|
|
||||||
getCurrentWeather,
|
|
||||||
getWeatherForecast,
|
|
||||||
getHourlyWeatherForecast,
|
|
||||||
getHistoricalWeather,
|
|
||||||
getCurrentTraffic,
|
|
||||||
getTrafficForecast,
|
|
||||||
getHistoricalTraffic,
|
|
||||||
testConnectivity,
|
|
||||||
clearError: () => setError(null),
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -1,229 +0,0 @@
|
|||||||
// frontend/src/api/hooks/useForecast.ts
|
|
||||||
/**
|
|
||||||
* Forecasting Operations Hooks
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { useState, useCallback } from 'react';
|
|
||||||
import { forecastingService } from '../services';
|
|
||||||
import type {
|
|
||||||
SingleForecastRequest,
|
|
||||||
BatchForecastRequest,
|
|
||||||
ForecastResponse,
|
|
||||||
BatchForecastResponse,
|
|
||||||
ForecastAlert,
|
|
||||||
QuickForecast,
|
|
||||||
} from '../types';
|
|
||||||
|
|
||||||
export const useForecast = () => {
|
|
||||||
const [forecasts, setForecasts] = useState<ForecastResponse[]>([]);
|
|
||||||
const [batchForecasts, setBatchForecasts] = useState<BatchForecastResponse[]>([]);
|
|
||||||
const [quickForecasts, setQuickForecasts] = useState<QuickForecast[]>([]);
|
|
||||||
const [alerts, setAlerts] = useState<ForecastAlert[]>([]);
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const createSingleForecast = useCallback(async (
|
|
||||||
tenantId: string,
|
|
||||||
request: SingleForecastRequest
|
|
||||||
): Promise<ForecastResponse[]> => {
|
|
||||||
try {
|
|
||||||
setIsLoading(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
const newForecasts = await forecastingService.createSingleForecast(tenantId, request);
|
|
||||||
setForecasts(prev => [...newForecasts, ...(prev || [])]);
|
|
||||||
|
|
||||||
return newForecasts;
|
|
||||||
} catch (error) {
|
|
||||||
const message = error instanceof Error ? error.message : 'Failed to create forecast';
|
|
||||||
setError(message);
|
|
||||||
throw error;
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const createBatchForecast = useCallback(async (
|
|
||||||
tenantId: string,
|
|
||||||
request: BatchForecastRequest
|
|
||||||
): Promise<BatchForecastResponse> => {
|
|
||||||
try {
|
|
||||||
setIsLoading(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
const batchForecast = await forecastingService.createBatchForecast(tenantId, request);
|
|
||||||
setBatchForecasts(prev => [batchForecast, ...(prev || [])]);
|
|
||||||
|
|
||||||
return batchForecast;
|
|
||||||
} catch (error) {
|
|
||||||
const message = error instanceof Error ? error.message : 'Failed to create batch forecast';
|
|
||||||
setError(message);
|
|
||||||
throw error;
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const getForecasts = useCallback(async (tenantId: string): Promise<ForecastResponse[]> => {
|
|
||||||
try {
|
|
||||||
setIsLoading(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
const response = await forecastingService.getForecasts(tenantId);
|
|
||||||
setForecasts(response.data);
|
|
||||||
|
|
||||||
return response.data;
|
|
||||||
} catch (error) {
|
|
||||||
const message = error instanceof Error ? error.message : 'Failed to get forecasts';
|
|
||||||
setError(message);
|
|
||||||
throw error;
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const getBatchForecastStatus = useCallback(async (
|
|
||||||
tenantId: string,
|
|
||||||
batchId: string
|
|
||||||
): Promise<BatchForecastResponse> => {
|
|
||||||
try {
|
|
||||||
const batchForecast = await forecastingService.getBatchForecastStatus(tenantId, batchId);
|
|
||||||
|
|
||||||
// Update batch forecast in state
|
|
||||||
setBatchForecasts(prev => (prev || []).map(bf =>
|
|
||||||
bf.id === batchId ? batchForecast : bf
|
|
||||||
));
|
|
||||||
|
|
||||||
return batchForecast;
|
|
||||||
} catch (error) {
|
|
||||||
const message = error instanceof Error ? error.message : 'Failed to get batch forecast status';
|
|
||||||
setError(message);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const getQuickForecasts = useCallback(async (tenantId: string): Promise<QuickForecast[]> => {
|
|
||||||
try {
|
|
||||||
setIsLoading(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
const quickForecastData = await forecastingService.getQuickForecasts(tenantId);
|
|
||||||
setQuickForecasts(quickForecastData);
|
|
||||||
|
|
||||||
return quickForecastData;
|
|
||||||
} catch (error) {
|
|
||||||
const message = error instanceof Error ? error.message : 'Failed to get quick forecasts';
|
|
||||||
setError(message);
|
|
||||||
throw error;
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const getForecastAlerts = useCallback(async (tenantId: string): Promise<any> => {
|
|
||||||
try {
|
|
||||||
setIsLoading(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
const response = await forecastingService.getForecastAlerts(tenantId);
|
|
||||||
|
|
||||||
// Handle different response formats
|
|
||||||
if (response && 'data' in response && response.data) {
|
|
||||||
// Standard paginated format: { data: [...], pagination: {...} }
|
|
||||||
setAlerts(response.data);
|
|
||||||
return { alerts: response.data, ...response };
|
|
||||||
} else if (response && Array.isArray(response)) {
|
|
||||||
// Direct array format
|
|
||||||
setAlerts(response);
|
|
||||||
return { alerts: response };
|
|
||||||
} else if (Array.isArray(response)) {
|
|
||||||
// Direct array format
|
|
||||||
setAlerts(response);
|
|
||||||
return { alerts: response };
|
|
||||||
} else {
|
|
||||||
// Unknown format - return empty
|
|
||||||
setAlerts([]);
|
|
||||||
return { alerts: [] };
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
const message = error instanceof Error ? error.message : 'Failed to get forecast alerts';
|
|
||||||
setError(message);
|
|
||||||
throw error;
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const acknowledgeForecastAlert = useCallback(async (
|
|
||||||
tenantId: string,
|
|
||||||
alertId: string
|
|
||||||
): Promise<void> => {
|
|
||||||
try {
|
|
||||||
setIsLoading(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
const acknowledgedAlert = await forecastingService.acknowledgeForecastAlert(tenantId, alertId);
|
|
||||||
setAlerts(prev => (prev || []).map(alert =>
|
|
||||||
alert.id === alertId ? acknowledgedAlert : alert
|
|
||||||
));
|
|
||||||
} catch (error) {
|
|
||||||
const message = error instanceof Error ? error.message : 'Failed to acknowledge alert';
|
|
||||||
setError(message);
|
|
||||||
throw error;
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const exportForecasts = useCallback(async (
|
|
||||||
tenantId: string,
|
|
||||||
format: 'csv' | 'excel' | 'json',
|
|
||||||
params?: {
|
|
||||||
inventory_product_id?: string; // Primary way to filter by product
|
|
||||||
product_name?: string; // For backward compatibility
|
|
||||||
start_date?: string;
|
|
||||||
end_date?: string;
|
|
||||||
}
|
|
||||||
): Promise<void> => {
|
|
||||||
try {
|
|
||||||
setIsLoading(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
const blob = await forecastingService.exportForecasts(tenantId, format, params);
|
|
||||||
|
|
||||||
// Create download link
|
|
||||||
const url = window.URL.createObjectURL(blob);
|
|
||||||
const link = document.createElement('a');
|
|
||||||
link.href = url;
|
|
||||||
link.download = `forecasts.${format}`;
|
|
||||||
document.body.appendChild(link);
|
|
||||||
link.click();
|
|
||||||
document.body.removeChild(link);
|
|
||||||
window.URL.revokeObjectURL(url);
|
|
||||||
} catch (error) {
|
|
||||||
const message = error instanceof Error ? error.message : 'Export failed';
|
|
||||||
setError(message);
|
|
||||||
throw error;
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return {
|
|
||||||
forecasts,
|
|
||||||
batchForecasts,
|
|
||||||
quickForecasts,
|
|
||||||
alerts,
|
|
||||||
isLoading,
|
|
||||||
error,
|
|
||||||
createSingleForecast,
|
|
||||||
createBatchForecast,
|
|
||||||
getForecasts,
|
|
||||||
getBatchForecastStatus,
|
|
||||||
getQuickForecasts,
|
|
||||||
getForecastAlerts,
|
|
||||||
acknowledgeForecastAlert,
|
|
||||||
exportForecasts,
|
|
||||||
clearError: () => setError(null),
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -1,536 +0,0 @@
|
|||||||
// frontend/src/api/hooks/useInventory.ts
|
|
||||||
/**
|
|
||||||
* Inventory Management React Hook
|
|
||||||
* Provides comprehensive state management for inventory operations
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { useState, useEffect, useCallback } from 'react';
|
|
||||||
import toast from 'react-hot-toast';
|
|
||||||
|
|
||||||
import {
|
|
||||||
inventoryService,
|
|
||||||
InventoryItem,
|
|
||||||
StockLevel,
|
|
||||||
StockMovement,
|
|
||||||
InventorySearchParams,
|
|
||||||
CreateInventoryItemRequest,
|
|
||||||
UpdateInventoryItemRequest,
|
|
||||||
StockAdjustmentRequest,
|
|
||||||
PaginatedResponse,
|
|
||||||
InventoryDashboardData
|
|
||||||
} from '../services/inventory.service';
|
|
||||||
import type { ProductInfo } from '../types';
|
|
||||||
|
|
||||||
import { useTenantId } from '../../hooks/useTenantId';
|
|
||||||
|
|
||||||
// ========== HOOK INTERFACES ==========
|
|
||||||
|
|
||||||
interface UseInventoryReturn {
|
|
||||||
// State
|
|
||||||
items: InventoryItem[];
|
|
||||||
stockLevels: Record<string, StockLevel>;
|
|
||||||
movements: StockMovement[];
|
|
||||||
dashboardData: InventoryDashboardData | null;
|
|
||||||
isLoading: boolean;
|
|
||||||
error: string | null;
|
|
||||||
pagination: {
|
|
||||||
page: number;
|
|
||||||
limit: number;
|
|
||||||
total: number;
|
|
||||||
totalPages: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Actions
|
|
||||||
loadItems: (params?: InventorySearchParams) => Promise<void>;
|
|
||||||
loadItem: (itemId: string) => Promise<InventoryItem | null>;
|
|
||||||
createItem: (data: CreateInventoryItemRequest) => Promise<InventoryItem | null>;
|
|
||||||
updateItem: (itemId: string, data: UpdateInventoryItemRequest) => Promise<InventoryItem | null>;
|
|
||||||
deleteItem: (itemId: string) => Promise<boolean>;
|
|
||||||
|
|
||||||
// Stock operations
|
|
||||||
loadStockLevels: () => Promise<void>;
|
|
||||||
adjustStock: (itemId: string, adjustment: StockAdjustmentRequest) => Promise<StockMovement | null>;
|
|
||||||
loadMovements: (params?: any) => Promise<void>;
|
|
||||||
|
|
||||||
|
|
||||||
// Dashboard
|
|
||||||
loadDashboard: () => Promise<void>;
|
|
||||||
|
|
||||||
// Utility
|
|
||||||
searchItems: (query: string) => Promise<InventoryItem[]>;
|
|
||||||
refresh: () => Promise<void>;
|
|
||||||
clearError: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface UseInventoryDashboardReturn {
|
|
||||||
dashboardData: InventoryDashboardData | null;
|
|
||||||
isLoading: boolean;
|
|
||||||
error: string | null;
|
|
||||||
refresh: () => Promise<void>;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface UseInventoryItemReturn {
|
|
||||||
item: InventoryItem | null;
|
|
||||||
stockLevel: StockLevel | null;
|
|
||||||
recentMovements: StockMovement[];
|
|
||||||
isLoading: boolean;
|
|
||||||
error: string | null;
|
|
||||||
updateItem: (data: UpdateInventoryItemRequest) => Promise<boolean>;
|
|
||||||
adjustStock: (adjustment: StockAdjustmentRequest) => Promise<boolean>;
|
|
||||||
refresh: () => Promise<void>;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========== MAIN INVENTORY HOOK ==========
|
|
||||||
|
|
||||||
export const useInventory = (autoLoad = true): UseInventoryReturn => {
|
|
||||||
const { tenantId } = useTenantId();
|
|
||||||
|
|
||||||
// State
|
|
||||||
const [items, setItems] = useState<InventoryItem[]>([]);
|
|
||||||
const [stockLevels, setStockLevels] = useState<Record<string, StockLevel>>({});
|
|
||||||
const [movements, setMovements] = useState<StockMovement[]>([]);
|
|
||||||
const [dashboardData, setDashboardData] = useState<InventoryDashboardData | null>(null);
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [pagination, setPagination] = useState({
|
|
||||||
page: 1,
|
|
||||||
limit: 20,
|
|
||||||
total: 0,
|
|
||||||
totalPages: 0
|
|
||||||
});
|
|
||||||
|
|
||||||
// Clear error
|
|
||||||
const clearError = useCallback(() => setError(null), []);
|
|
||||||
|
|
||||||
// Load inventory items
|
|
||||||
const loadItems = useCallback(async (params?: InventorySearchParams) => {
|
|
||||||
if (!tenantId) return;
|
|
||||||
|
|
||||||
setIsLoading(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await inventoryService.getInventoryItems(tenantId, params);
|
|
||||||
console.log('🔄 useInventory: Loaded items:', response.items);
|
|
||||||
setItems(response.items || []); // Ensure it's always an array
|
|
||||||
setPagination({
|
|
||||||
page: response.page || 1,
|
|
||||||
limit: response.limit || 20,
|
|
||||||
total: response.total || 0,
|
|
||||||
totalPages: response.total_pages || 0
|
|
||||||
});
|
|
||||||
} catch (err: any) {
|
|
||||||
console.error('❌ useInventory: Error loading items:', err);
|
|
||||||
const errorMessage = err.response?.data?.detail || err.message || 'Error loading inventory items';
|
|
||||||
|
|
||||||
setError(errorMessage);
|
|
||||||
setItems([]); // Set empty array on error
|
|
||||||
|
|
||||||
// Show appropriate error message
|
|
||||||
if (err.response?.status === 401) {
|
|
||||||
console.error('❌ useInventory: Authentication failed');
|
|
||||||
} else if (err.response?.status === 403) {
|
|
||||||
toast.error('No tienes permisos para acceder a este inventario');
|
|
||||||
} else {
|
|
||||||
toast.error(errorMessage);
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
}, [tenantId]);
|
|
||||||
|
|
||||||
// Load single item
|
|
||||||
const loadItem = useCallback(async (itemId: string): Promise<InventoryItem | null> => {
|
|
||||||
if (!tenantId) return null;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const item = await inventoryService.getInventoryItem(tenantId, itemId);
|
|
||||||
|
|
||||||
// Update in local state if it exists
|
|
||||||
setItems(prev => prev.map(i => i.id === itemId ? item : i));
|
|
||||||
|
|
||||||
return item;
|
|
||||||
} catch (err: any) {
|
|
||||||
const errorMessage = err.response?.data?.detail || err.message || 'Error loading item';
|
|
||||||
setError(errorMessage);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}, [tenantId]);
|
|
||||||
|
|
||||||
// Create item
|
|
||||||
const createItem = useCallback(async (data: CreateInventoryItemRequest): Promise<InventoryItem | null> => {
|
|
||||||
if (!tenantId) return null;
|
|
||||||
|
|
||||||
setIsLoading(true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const newItem = await inventoryService.createInventoryItem(tenantId, data);
|
|
||||||
setItems(prev => [newItem, ...prev]);
|
|
||||||
toast.success(`Created ${newItem.name} successfully`);
|
|
||||||
return newItem;
|
|
||||||
} catch (err: any) {
|
|
||||||
const errorMessage = err.response?.data?.detail || err.message || 'Error creating item';
|
|
||||||
setError(errorMessage);
|
|
||||||
toast.error(errorMessage);
|
|
||||||
return null;
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
}, [tenantId]);
|
|
||||||
|
|
||||||
// Update item
|
|
||||||
const updateItem = useCallback(async (
|
|
||||||
itemId: string,
|
|
||||||
data: UpdateInventoryItemRequest
|
|
||||||
): Promise<InventoryItem | null> => {
|
|
||||||
if (!tenantId) return null;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const updatedItem = await inventoryService.updateInventoryItem(tenantId, itemId, data);
|
|
||||||
setItems(prev => prev.map(i => i.id === itemId ? updatedItem : i));
|
|
||||||
toast.success(`Updated ${updatedItem.name} successfully`);
|
|
||||||
return updatedItem;
|
|
||||||
} catch (err: any) {
|
|
||||||
const errorMessage = err.response?.data?.detail || err.message || 'Error updating item';
|
|
||||||
setError(errorMessage);
|
|
||||||
toast.error(errorMessage);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}, [tenantId]);
|
|
||||||
|
|
||||||
// Delete item
|
|
||||||
const deleteItem = useCallback(async (itemId: string): Promise<boolean> => {
|
|
||||||
if (!tenantId) return false;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await inventoryService.deleteInventoryItem(tenantId, itemId);
|
|
||||||
setItems(prev => prev.filter(i => i.id !== itemId));
|
|
||||||
toast.success('Item deleted successfully');
|
|
||||||
return true;
|
|
||||||
} catch (err: any) {
|
|
||||||
const errorMessage = err.response?.data?.detail || err.message || 'Error deleting item';
|
|
||||||
setError(errorMessage);
|
|
||||||
toast.error(errorMessage);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}, [tenantId]);
|
|
||||||
|
|
||||||
// Load stock levels
|
|
||||||
const loadStockLevels = useCallback(async () => {
|
|
||||||
if (!tenantId) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const levels = await inventoryService.getAllStockLevels(tenantId);
|
|
||||||
const levelMap = levels.reduce((acc, level) => {
|
|
||||||
acc[level.item_id] = level;
|
|
||||||
return acc;
|
|
||||||
}, {} as Record<string, StockLevel>);
|
|
||||||
setStockLevels(levelMap);
|
|
||||||
} catch (err: any) {
|
|
||||||
console.error('Error loading stock levels:', err);
|
|
||||||
// Don't show toast error for this as it's not critical for forecast page
|
|
||||||
}
|
|
||||||
}, [tenantId]);
|
|
||||||
|
|
||||||
// Adjust stock
|
|
||||||
const adjustStock = useCallback(async (
|
|
||||||
itemId: string,
|
|
||||||
adjustment: StockAdjustmentRequest
|
|
||||||
): Promise<StockMovement | null> => {
|
|
||||||
if (!tenantId) return null;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const movement = await inventoryService.adjustStock(tenantId, itemId, adjustment);
|
|
||||||
|
|
||||||
// Update local movements
|
|
||||||
setMovements(prev => [movement, ...prev.slice(0, 49)]); // Keep last 50
|
|
||||||
|
|
||||||
// Reload stock level for this item
|
|
||||||
const updatedLevel = await inventoryService.getStockLevel(tenantId, itemId);
|
|
||||||
setStockLevels(prev => ({ ...prev, [itemId]: updatedLevel }));
|
|
||||||
|
|
||||||
toast.success('Stock adjusted successfully');
|
|
||||||
return movement;
|
|
||||||
} catch (err: any) {
|
|
||||||
const errorMessage = err.response?.data?.detail || err.message || 'Error adjusting stock';
|
|
||||||
setError(errorMessage);
|
|
||||||
toast.error(errorMessage);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}, [tenantId]);
|
|
||||||
|
|
||||||
// Load movements
|
|
||||||
const loadMovements = useCallback(async (params?: any) => {
|
|
||||||
if (!tenantId) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await inventoryService.getStockMovements(tenantId, params);
|
|
||||||
setMovements(response.items);
|
|
||||||
} catch (err: any) {
|
|
||||||
console.error('Error loading movements:', err);
|
|
||||||
}
|
|
||||||
}, [tenantId]);
|
|
||||||
|
|
||||||
|
|
||||||
// Load dashboard
|
|
||||||
const loadDashboard = useCallback(async () => {
|
|
||||||
if (!tenantId) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const data = await inventoryService.getDashboardData(tenantId);
|
|
||||||
setDashboardData(data);
|
|
||||||
} catch (err: any) {
|
|
||||||
console.error('Error loading dashboard:', err);
|
|
||||||
// Don't show toast error for this as it's not critical for forecast page
|
|
||||||
}
|
|
||||||
}, [tenantId]);
|
|
||||||
|
|
||||||
// Search items
|
|
||||||
const searchItems = useCallback(async (query: string): Promise<InventoryItem[]> => {
|
|
||||||
if (!tenantId || !query.trim()) return [];
|
|
||||||
|
|
||||||
try {
|
|
||||||
return await inventoryService.searchItems(tenantId, query);
|
|
||||||
} catch (err: any) {
|
|
||||||
console.error('Error searching items:', err);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}, [tenantId]);
|
|
||||||
|
|
||||||
// Refresh all data
|
|
||||||
const refresh = useCallback(async () => {
|
|
||||||
await Promise.all([
|
|
||||||
loadItems(),
|
|
||||||
loadStockLevels(),
|
|
||||||
loadDashboard()
|
|
||||||
]);
|
|
||||||
}, [loadItems, loadStockLevels, loadDashboard]);
|
|
||||||
|
|
||||||
// Auto-load on mount
|
|
||||||
useEffect(() => {
|
|
||||||
if (autoLoad && tenantId) {
|
|
||||||
refresh();
|
|
||||||
}
|
|
||||||
}, [autoLoad, tenantId, refresh]);
|
|
||||||
|
|
||||||
return {
|
|
||||||
// State
|
|
||||||
items,
|
|
||||||
stockLevels,
|
|
||||||
movements,
|
|
||||||
dashboardData,
|
|
||||||
isLoading,
|
|
||||||
error,
|
|
||||||
pagination,
|
|
||||||
|
|
||||||
// Actions
|
|
||||||
loadItems,
|
|
||||||
loadItem,
|
|
||||||
createItem,
|
|
||||||
updateItem,
|
|
||||||
deleteItem,
|
|
||||||
|
|
||||||
// Stock operations
|
|
||||||
loadStockLevels,
|
|
||||||
adjustStock,
|
|
||||||
loadMovements,
|
|
||||||
|
|
||||||
// Dashboard
|
|
||||||
loadDashboard,
|
|
||||||
|
|
||||||
// Utility
|
|
||||||
searchItems,
|
|
||||||
refresh,
|
|
||||||
clearError
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
// ========== DASHBOARD HOOK ==========
|
|
||||||
|
|
||||||
export const useInventoryDashboard = (): UseInventoryDashboardReturn => {
|
|
||||||
const { tenantId } = useTenantId();
|
|
||||||
const [dashboardData, setDashboardData] = useState<InventoryDashboardData | null>(null);
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const refresh = useCallback(async () => {
|
|
||||||
if (!tenantId) return;
|
|
||||||
|
|
||||||
setIsLoading(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const dashboard = await inventoryService.getDashboardData(tenantId);
|
|
||||||
|
|
||||||
setDashboardData(dashboard);
|
|
||||||
} catch (err: any) {
|
|
||||||
const errorMessage = err.response?.data?.detail || err.message || 'Error loading dashboard';
|
|
||||||
setError(errorMessage);
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
}, [tenantId]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (tenantId) {
|
|
||||||
refresh();
|
|
||||||
}
|
|
||||||
}, [tenantId, refresh]);
|
|
||||||
|
|
||||||
return {
|
|
||||||
dashboardData,
|
|
||||||
isLoading,
|
|
||||||
error,
|
|
||||||
refresh
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
// ========== SINGLE ITEM HOOK ==========
|
|
||||||
|
|
||||||
export const useInventoryItem = (itemId: string): UseInventoryItemReturn => {
|
|
||||||
const { tenantId } = useTenantId();
|
|
||||||
const [item, setItem] = useState<InventoryItem | null>(null);
|
|
||||||
const [stockLevel, setStockLevel] = useState<StockLevel | null>(null);
|
|
||||||
const [recentMovements, setRecentMovements] = useState<StockMovement[]>([]);
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const refresh = useCallback(async () => {
|
|
||||||
if (!tenantId || !itemId) return;
|
|
||||||
|
|
||||||
setIsLoading(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const [itemData, stockData, movementsData] = await Promise.all([
|
|
||||||
inventoryService.getInventoryItem(tenantId, itemId),
|
|
||||||
inventoryService.getStockLevel(tenantId, itemId),
|
|
||||||
inventoryService.getStockMovements(tenantId, { item_id: itemId, limit: 10 })
|
|
||||||
]);
|
|
||||||
|
|
||||||
setItem(itemData);
|
|
||||||
setStockLevel(stockData);
|
|
||||||
setRecentMovements(movementsData.items);
|
|
||||||
} catch (err: any) {
|
|
||||||
const errorMessage = err.response?.data?.detail || err.message || 'Error loading item';
|
|
||||||
setError(errorMessage);
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
}, [tenantId, itemId]);
|
|
||||||
|
|
||||||
const updateItem = useCallback(async (data: UpdateInventoryItemRequest): Promise<boolean> => {
|
|
||||||
if (!tenantId || !itemId) return false;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const updatedItem = await inventoryService.updateInventoryItem(tenantId, itemId, data);
|
|
||||||
setItem(updatedItem);
|
|
||||||
toast.success('Item updated successfully');
|
|
||||||
return true;
|
|
||||||
} catch (err: any) {
|
|
||||||
const errorMessage = err.response?.data?.detail || err.message || 'Error updating item';
|
|
||||||
setError(errorMessage);
|
|
||||||
toast.error(errorMessage);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}, [tenantId, itemId]);
|
|
||||||
|
|
||||||
const adjustStock = useCallback(async (adjustment: StockAdjustmentRequest): Promise<boolean> => {
|
|
||||||
if (!tenantId || !itemId) return false;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const movement = await inventoryService.adjustStock(tenantId, itemId, adjustment);
|
|
||||||
|
|
||||||
// Refresh data
|
|
||||||
const [updatedStock, updatedMovements] = await Promise.all([
|
|
||||||
inventoryService.getStockLevel(tenantId, itemId),
|
|
||||||
inventoryService.getStockMovements(tenantId, { item_id: itemId, limit: 10 })
|
|
||||||
]);
|
|
||||||
|
|
||||||
setStockLevel(updatedStock);
|
|
||||||
setRecentMovements(updatedMovements.items);
|
|
||||||
|
|
||||||
toast.success('Stock adjusted successfully');
|
|
||||||
return true;
|
|
||||||
} catch (err: any) {
|
|
||||||
const errorMessage = err.response?.data?.detail || err.message || 'Error adjusting stock';
|
|
||||||
setError(errorMessage);
|
|
||||||
toast.error(errorMessage);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}, [tenantId, itemId]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (tenantId && itemId) {
|
|
||||||
refresh();
|
|
||||||
}
|
|
||||||
}, [tenantId, itemId, refresh]);
|
|
||||||
|
|
||||||
return {
|
|
||||||
item,
|
|
||||||
stockLevel,
|
|
||||||
recentMovements,
|
|
||||||
isLoading,
|
|
||||||
error,
|
|
||||||
updateItem,
|
|
||||||
adjustStock,
|
|
||||||
refresh
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
// ========== SIMPLE PRODUCTS HOOK FOR FORECASTING ==========
|
|
||||||
|
|
||||||
export const useInventoryProducts = () => {
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get Products List for Forecasting
|
|
||||||
*/
|
|
||||||
const getProductsList = useCallback(async (tenantId: string): Promise<ProductInfo[]> => {
|
|
||||||
try {
|
|
||||||
setIsLoading(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
const products = await inventoryService.getProductsList(tenantId);
|
|
||||||
|
|
||||||
return products;
|
|
||||||
} catch (error) {
|
|
||||||
const message = error instanceof Error ? error.message : 'Failed to get products list';
|
|
||||||
setError(message);
|
|
||||||
throw error;
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get Product by ID
|
|
||||||
*/
|
|
||||||
const getProductById = useCallback(async (tenantId: string, productId: string): Promise<ProductInfo | null> => {
|
|
||||||
try {
|
|
||||||
setIsLoading(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
const product = await inventoryService.getProductById(tenantId, productId);
|
|
||||||
|
|
||||||
return product;
|
|
||||||
} catch (error) {
|
|
||||||
const message = error instanceof Error ? error.message : 'Failed to get product';
|
|
||||||
setError(message);
|
|
||||||
throw error;
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return {
|
|
||||||
// State
|
|
||||||
isLoading,
|
|
||||||
error,
|
|
||||||
|
|
||||||
// Actions
|
|
||||||
getProductsList,
|
|
||||||
getProductById,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -1,151 +0,0 @@
|
|||||||
// frontend/src/api/hooks/useNotification.ts
|
|
||||||
/**
|
|
||||||
* Notification Operations Hooks
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { useState, useCallback } from 'react';
|
|
||||||
import { notificationService } from '../services';
|
|
||||||
import type {
|
|
||||||
NotificationCreate,
|
|
||||||
NotificationResponse,
|
|
||||||
NotificationTemplate,
|
|
||||||
NotificationStats,
|
|
||||||
BulkNotificationRequest,
|
|
||||||
} from '../types';
|
|
||||||
|
|
||||||
export const useNotification = () => {
|
|
||||||
const [notifications, setNotifications] = useState<NotificationResponse[]>([]);
|
|
||||||
const [templates, setTemplates] = useState<NotificationTemplate[]>([]);
|
|
||||||
const [stats, setStats] = useState<NotificationStats | null>(null);
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const sendNotification = useCallback(async (
|
|
||||||
tenantId: string,
|
|
||||||
notification: NotificationCreate
|
|
||||||
): Promise<NotificationResponse> => {
|
|
||||||
try {
|
|
||||||
setIsLoading(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
const sentNotification = await notificationService.sendNotification(tenantId, notification);
|
|
||||||
setNotifications(prev => [sentNotification, ...prev]);
|
|
||||||
|
|
||||||
return sentNotification;
|
|
||||||
} catch (error) {
|
|
||||||
const message = error instanceof Error ? error.message : 'Failed to send notification';
|
|
||||||
setError(message);
|
|
||||||
throw error;
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const sendBulkNotifications = useCallback(async (
|
|
||||||
tenantId: string,
|
|
||||||
request: BulkNotificationRequest
|
|
||||||
): Promise<void> => {
|
|
||||||
try {
|
|
||||||
setIsLoading(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
await notificationService.sendBulkNotifications(tenantId, request);
|
|
||||||
} catch (error) {
|
|
||||||
const message = error instanceof Error ? error.message : 'Failed to send bulk notifications';
|
|
||||||
setError(message);
|
|
||||||
throw error;
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const getNotifications = useCallback(async (tenantId: string): Promise<NotificationResponse[]> => {
|
|
||||||
try {
|
|
||||||
setIsLoading(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
const response = await notificationService.getNotifications(tenantId);
|
|
||||||
setNotifications(response.data);
|
|
||||||
|
|
||||||
return response.data;
|
|
||||||
} catch (error) {
|
|
||||||
const message = error instanceof Error ? error.message : 'Failed to get notifications';
|
|
||||||
setError(message);
|
|
||||||
throw error;
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const getTemplates = useCallback(async (tenantId: string): Promise<NotificationTemplate[]> => {
|
|
||||||
try {
|
|
||||||
setIsLoading(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
const response = await notificationService.getTemplates(tenantId);
|
|
||||||
setTemplates(response.data);
|
|
||||||
|
|
||||||
return response.data;
|
|
||||||
} catch (error) {
|
|
||||||
const message = error instanceof Error ? error.message : 'Failed to get templates';
|
|
||||||
setError(message);
|
|
||||||
throw error;
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const createTemplate = useCallback(async (
|
|
||||||
tenantId: string,
|
|
||||||
template: Omit<NotificationTemplate, 'id' | 'tenant_id' | 'created_at' | 'updated_at'>
|
|
||||||
): Promise<NotificationTemplate> => {
|
|
||||||
try {
|
|
||||||
setIsLoading(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
const newTemplate = await notificationService.createTemplate(tenantId, template);
|
|
||||||
setTemplates(prev => [newTemplate, ...prev]);
|
|
||||||
|
|
||||||
return newTemplate;
|
|
||||||
} catch (error) {
|
|
||||||
const message = error instanceof Error ? error.message : 'Failed to create template';
|
|
||||||
setError(message);
|
|
||||||
throw error;
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const getNotificationStats = useCallback(async (tenantId: string): Promise<NotificationStats> => {
|
|
||||||
try {
|
|
||||||
setIsLoading(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
const notificationStats = await notificationService.getNotificationStats(tenantId);
|
|
||||||
setStats(notificationStats);
|
|
||||||
|
|
||||||
return notificationStats;
|
|
||||||
} catch (error) {
|
|
||||||
const message = error instanceof Error ? error.message : 'Failed to get notification stats';
|
|
||||||
setError(message);
|
|
||||||
throw error;
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return {
|
|
||||||
notifications,
|
|
||||||
templates,
|
|
||||||
stats,
|
|
||||||
isLoading,
|
|
||||||
error,
|
|
||||||
sendNotification,
|
|
||||||
sendBulkNotifications,
|
|
||||||
getNotifications,
|
|
||||||
getTemplates,
|
|
||||||
createTemplate,
|
|
||||||
getNotificationStats,
|
|
||||||
clearError: () => setError(null),
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -1,194 +0,0 @@
|
|||||||
// frontend/src/api/hooks/useOnboarding.ts
|
|
||||||
/**
|
|
||||||
* Onboarding Hook
|
|
||||||
* React hook for managing user onboarding flow and progress
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
|
||||||
import { onboardingService } from '../services/onboarding.service';
|
|
||||||
import type { UserProgress, UpdateStepRequest } from '../services/onboarding.service';
|
|
||||||
|
|
||||||
export interface UseOnboardingReturn {
|
|
||||||
progress: UserProgress | null;
|
|
||||||
isLoading: boolean;
|
|
||||||
error: string | null;
|
|
||||||
currentStep: string | null;
|
|
||||||
nextStep: string | null;
|
|
||||||
completionPercentage: number;
|
|
||||||
isFullyComplete: boolean;
|
|
||||||
|
|
||||||
// Actions
|
|
||||||
updateStep: (data: UpdateStepRequest) => Promise<void>;
|
|
||||||
completeStep: (stepName: string, data?: Record<string, any>) => Promise<void>;
|
|
||||||
resetStep: (stepName: string) => Promise<void>;
|
|
||||||
getNextStep: () => Promise<string>;
|
|
||||||
completeOnboarding: () => Promise<void>;
|
|
||||||
canAccessStep: (stepName: string) => Promise<boolean>;
|
|
||||||
refreshProgress: () => Promise<void>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useOnboarding = (): UseOnboardingReturn => {
|
|
||||||
const [progress, setProgress] = useState<UserProgress | null>(null);
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
// Derived state
|
|
||||||
const currentStep = progress?.current_step || null;
|
|
||||||
const nextStep = progress?.next_step || null;
|
|
||||||
const completionPercentage = progress?.completion_percentage || 0;
|
|
||||||
const isFullyComplete = progress?.fully_completed || false;
|
|
||||||
|
|
||||||
// Load initial progress
|
|
||||||
const loadProgress = async () => {
|
|
||||||
setIsLoading(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const userProgress = await onboardingService.getUserProgress();
|
|
||||||
setProgress(userProgress);
|
|
||||||
} catch (err) {
|
|
||||||
const message = err instanceof Error ? err.message : 'Failed to load onboarding progress';
|
|
||||||
setError(message);
|
|
||||||
console.error('Onboarding progress load error:', err);
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Update step
|
|
||||||
const updateStep = async (data: UpdateStepRequest) => {
|
|
||||||
setIsLoading(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const updatedProgress = await onboardingService.updateStep(data);
|
|
||||||
setProgress(updatedProgress);
|
|
||||||
} catch (err) {
|
|
||||||
const message = err instanceof Error ? err.message : 'Failed to update step';
|
|
||||||
setError(message);
|
|
||||||
throw err; // Re-throw so calling component can handle it
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Complete step with data
|
|
||||||
const completeStep = async (stepName: string, data?: Record<string, any>) => {
|
|
||||||
await updateStep({
|
|
||||||
step_name: stepName,
|
|
||||||
completed: true,
|
|
||||||
data
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// Reset step
|
|
||||||
const resetStep = async (stepName: string) => {
|
|
||||||
await updateStep({
|
|
||||||
step_name: stepName,
|
|
||||||
completed: false
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// Get next step
|
|
||||||
const getNextStep = async (): Promise<string> => {
|
|
||||||
try {
|
|
||||||
const result = await onboardingService.getNextStep();
|
|
||||||
return result.step;
|
|
||||||
} catch (err) {
|
|
||||||
const message = err instanceof Error ? err.message : 'Failed to get next step';
|
|
||||||
setError(message);
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Complete entire onboarding
|
|
||||||
const completeOnboarding = async () => {
|
|
||||||
setIsLoading(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await onboardingService.completeOnboarding();
|
|
||||||
await loadProgress(); // Refresh progress after completion
|
|
||||||
} catch (err) {
|
|
||||||
const message = err instanceof Error ? err.message : 'Failed to complete onboarding';
|
|
||||||
setError(message);
|
|
||||||
throw err;
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Check if user can access step
|
|
||||||
const canAccessStep = async (stepName: string): Promise<boolean> => {
|
|
||||||
try {
|
|
||||||
const result = await onboardingService.canAccessStep(stepName);
|
|
||||||
return result.can_access;
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Can access step check failed:', err);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Refresh progress
|
|
||||||
const refreshProgress = async () => {
|
|
||||||
await loadProgress();
|
|
||||||
};
|
|
||||||
|
|
||||||
// Load progress on mount
|
|
||||||
useEffect(() => {
|
|
||||||
loadProgress();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return {
|
|
||||||
progress,
|
|
||||||
isLoading,
|
|
||||||
error,
|
|
||||||
currentStep,
|
|
||||||
nextStep,
|
|
||||||
completionPercentage,
|
|
||||||
isFullyComplete,
|
|
||||||
updateStep,
|
|
||||||
completeStep,
|
|
||||||
resetStep,
|
|
||||||
getNextStep,
|
|
||||||
completeOnboarding,
|
|
||||||
canAccessStep,
|
|
||||||
refreshProgress,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
// Helper hook for specific steps
|
|
||||||
export const useOnboardingStep = (stepName: string) => {
|
|
||||||
const onboarding = useOnboarding();
|
|
||||||
|
|
||||||
const stepStatus = onboarding.progress?.steps.find(
|
|
||||||
step => step.step_name === stepName
|
|
||||||
);
|
|
||||||
|
|
||||||
const isCompleted = stepStatus?.completed || false;
|
|
||||||
const stepData = stepStatus?.data || {};
|
|
||||||
const completedAt = stepStatus?.completed_at;
|
|
||||||
|
|
||||||
const completeThisStep = async (data?: Record<string, any>) => {
|
|
||||||
await onboarding.completeStep(stepName, data);
|
|
||||||
};
|
|
||||||
|
|
||||||
const resetThisStep = async () => {
|
|
||||||
await onboarding.resetStep(stepName);
|
|
||||||
};
|
|
||||||
|
|
||||||
const canAccessThisStep = async (): Promise<boolean> => {
|
|
||||||
return await onboarding.canAccessStep(stepName);
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
...onboarding,
|
|
||||||
stepName,
|
|
||||||
isCompleted,
|
|
||||||
stepData,
|
|
||||||
completedAt,
|
|
||||||
completeThisStep,
|
|
||||||
resetThisStep,
|
|
||||||
canAccessThisStep,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -1,337 +0,0 @@
|
|||||||
// frontend/src/api/hooks/usePOS.ts
|
|
||||||
/**
|
|
||||||
* React hooks for POS Integration functionality
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
|
||||||
import {
|
|
||||||
posService,
|
|
||||||
POSConfiguration,
|
|
||||||
CreatePOSConfigurationRequest,
|
|
||||||
UpdatePOSConfigurationRequest,
|
|
||||||
POSTransaction,
|
|
||||||
POSSyncLog,
|
|
||||||
POSAnalytics,
|
|
||||||
SyncRequest
|
|
||||||
} from '../services/pos.service';
|
|
||||||
import { useTenantId } from './useTenant';
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// CONFIGURATION HOOKS
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
export const usePOSConfigurations = (params?: {
|
|
||||||
pos_system?: string;
|
|
||||||
is_active?: boolean;
|
|
||||||
limit?: number;
|
|
||||||
offset?: number;
|
|
||||||
}) => {
|
|
||||||
const tenantId = useTenantId();
|
|
||||||
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ['pos-configurations', tenantId, params],
|
|
||||||
queryFn: () => posService.getConfigurations(tenantId, params),
|
|
||||||
enabled: !!tenantId,
|
|
||||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export const usePOSConfiguration = (configId?: string) => {
|
|
||||||
const tenantId = useTenantId();
|
|
||||||
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ['pos-configuration', tenantId, configId],
|
|
||||||
queryFn: () => posService.getConfiguration(tenantId, configId!),
|
|
||||||
enabled: !!tenantId && !!configId,
|
|
||||||
staleTime: 5 * 60 * 1000,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useCreatePOSConfiguration = () => {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const tenantId = useTenantId();
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: (data: CreatePOSConfigurationRequest) =>
|
|
||||||
posService.createConfiguration(tenantId, data),
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['pos-configurations', tenantId] });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useUpdatePOSConfiguration = () => {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const tenantId = useTenantId();
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: ({ configId, data }: { configId: string; data: UpdatePOSConfigurationRequest }) =>
|
|
||||||
posService.updateConfiguration(tenantId, configId, data),
|
|
||||||
onSuccess: (_, { configId }) => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['pos-configurations', tenantId] });
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['pos-configuration', tenantId, configId] });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useDeletePOSConfiguration = () => {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const tenantId = useTenantId();
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: (configId: string) =>
|
|
||||||
posService.deleteConfiguration(tenantId, configId),
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['pos-configurations', tenantId] });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useTestPOSConnection = () => {
|
|
||||||
const tenantId = useTenantId();
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: (configId: string) =>
|
|
||||||
posService.testConnection(tenantId, configId),
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// SYNCHRONIZATION HOOKS
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
export const useTriggerPOSSync = () => {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const tenantId = useTenantId();
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: ({ configId, syncRequest }: { configId: string; syncRequest: SyncRequest }) =>
|
|
||||||
posService.triggerSync(tenantId, configId, syncRequest),
|
|
||||||
onSuccess: (_, { configId }) => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['pos-sync-status', tenantId, configId] });
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['pos-sync-logs', tenantId, configId] });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export const usePOSSyncStatus = (configId?: string, pollingInterval?: number) => {
|
|
||||||
const tenantId = useTenantId();
|
|
||||||
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ['pos-sync-status', tenantId, configId],
|
|
||||||
queryFn: () => posService.getSyncStatus(tenantId, configId!),
|
|
||||||
enabled: !!tenantId && !!configId,
|
|
||||||
refetchInterval: pollingInterval || 30000, // Poll every 30 seconds by default
|
|
||||||
staleTime: 10 * 1000, // 10 seconds
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export const usePOSSyncLogs = (configId?: string, params?: {
|
|
||||||
limit?: number;
|
|
||||||
offset?: number;
|
|
||||||
status?: string;
|
|
||||||
sync_type?: string;
|
|
||||||
data_type?: string;
|
|
||||||
}) => {
|
|
||||||
const tenantId = useTenantId();
|
|
||||||
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ['pos-sync-logs', tenantId, configId, params],
|
|
||||||
queryFn: () => posService.getSyncLogs(tenantId, configId!, params),
|
|
||||||
enabled: !!tenantId && !!configId,
|
|
||||||
staleTime: 2 * 60 * 1000, // 2 minutes
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// TRANSACTION HOOKS
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
export const usePOSTransactions = (params?: {
|
|
||||||
pos_system?: string;
|
|
||||||
start_date?: string;
|
|
||||||
end_date?: string;
|
|
||||||
status?: string;
|
|
||||||
is_synced?: boolean;
|
|
||||||
limit?: number;
|
|
||||||
offset?: number;
|
|
||||||
}) => {
|
|
||||||
const tenantId = useTenantId();
|
|
||||||
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ['pos-transactions', tenantId, params],
|
|
||||||
queryFn: () => posService.getTransactions(tenantId, params),
|
|
||||||
enabled: !!tenantId,
|
|
||||||
staleTime: 2 * 60 * 1000, // 2 minutes
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useSyncSingleTransaction = () => {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const tenantId = useTenantId();
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: ({ transactionId, force }: { transactionId: string; force?: boolean }) =>
|
|
||||||
posService.syncSingleTransaction(tenantId, transactionId, force),
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['pos-transactions', tenantId] });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useResyncFailedTransactions = () => {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const tenantId = useTenantId();
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: (daysBack: number) =>
|
|
||||||
posService.resyncFailedTransactions(tenantId, daysBack),
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['pos-transactions', tenantId] });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// ANALYTICS HOOKS
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
export const usePOSAnalytics = (days: number = 30) => {
|
|
||||||
const tenantId = useTenantId();
|
|
||||||
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ['pos-analytics', tenantId, days],
|
|
||||||
queryFn: () => posService.getSyncAnalytics(tenantId, days),
|
|
||||||
enabled: !!tenantId,
|
|
||||||
staleTime: 10 * 60 * 1000, // 10 minutes
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// SYSTEM INFO HOOKS
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
export const useSupportedPOSSystems = () => {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ['supported-pos-systems'],
|
|
||||||
queryFn: () => posService.getSupportedSystems(),
|
|
||||||
staleTime: 60 * 60 * 1000, // 1 hour
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useWebhookStatus = (posSystem?: string) => {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ['webhook-status', posSystem],
|
|
||||||
queryFn: () => posService.getWebhookStatus(posSystem!),
|
|
||||||
enabled: !!posSystem,
|
|
||||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// COMPOSITE HOOKS
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
export const usePOSDashboard = () => {
|
|
||||||
const tenantId = useTenantId();
|
|
||||||
|
|
||||||
// Get configurations
|
|
||||||
const { data: configurationsData, isLoading: configurationsLoading } = usePOSConfigurations();
|
|
||||||
|
|
||||||
// Get recent transactions
|
|
||||||
const { data: transactionsData, isLoading: transactionsLoading } = usePOSTransactions({
|
|
||||||
limit: 10
|
|
||||||
});
|
|
||||||
|
|
||||||
// Get analytics for last 7 days
|
|
||||||
const { data: analyticsData, isLoading: analyticsLoading } = usePOSAnalytics(7);
|
|
||||||
|
|
||||||
const isLoading = configurationsLoading || transactionsLoading || analyticsLoading;
|
|
||||||
|
|
||||||
return {
|
|
||||||
configurations: configurationsData?.configurations || [],
|
|
||||||
transactions: transactionsData?.transactions || [],
|
|
||||||
analytics: analyticsData,
|
|
||||||
isLoading,
|
|
||||||
summary: {
|
|
||||||
total_configurations: configurationsData?.total || 0,
|
|
||||||
active_configurations: configurationsData?.configurations?.filter(c => c.is_active).length || 0,
|
|
||||||
connected_configurations: configurationsData?.configurations?.filter(c => c.is_connected).length || 0,
|
|
||||||
total_transactions: transactionsData?.total || 0,
|
|
||||||
total_revenue: transactionsData?.summary?.total_amount || 0,
|
|
||||||
sync_health: analyticsData?.success_rate || 0,
|
|
||||||
}
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export const usePOSConfigurationManagement = () => {
|
|
||||||
const createMutation = useCreatePOSConfiguration();
|
|
||||||
const updateMutation = useUpdatePOSConfiguration();
|
|
||||||
const deleteMutation = useDeletePOSConfiguration();
|
|
||||||
const testConnectionMutation = useTestPOSConnection();
|
|
||||||
|
|
||||||
const [selectedConfiguration, setSelectedConfiguration] = useState<POSConfiguration | null>(null);
|
|
||||||
const [isFormOpen, setIsFormOpen] = useState(false);
|
|
||||||
|
|
||||||
const handleCreate = async (data: CreatePOSConfigurationRequest) => {
|
|
||||||
await createMutation.mutateAsync(data);
|
|
||||||
setIsFormOpen(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleUpdate = async (configId: string, data: UpdatePOSConfigurationRequest) => {
|
|
||||||
await updateMutation.mutateAsync({ configId, data });
|
|
||||||
setIsFormOpen(false);
|
|
||||||
setSelectedConfiguration(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDelete = async (configId: string) => {
|
|
||||||
await deleteMutation.mutateAsync(configId);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleTestConnection = async (configId: string) => {
|
|
||||||
return await testConnectionMutation.mutateAsync(configId);
|
|
||||||
};
|
|
||||||
|
|
||||||
const openCreateForm = () => {
|
|
||||||
setSelectedConfiguration(null);
|
|
||||||
setIsFormOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const openEditForm = (configuration: POSConfiguration) => {
|
|
||||||
setSelectedConfiguration(configuration);
|
|
||||||
setIsFormOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const closeForm = () => {
|
|
||||||
setIsFormOpen(false);
|
|
||||||
setSelectedConfiguration(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
// State
|
|
||||||
selectedConfiguration,
|
|
||||||
isFormOpen,
|
|
||||||
|
|
||||||
// Actions
|
|
||||||
handleCreate,
|
|
||||||
handleUpdate,
|
|
||||||
handleDelete,
|
|
||||||
handleTestConnection,
|
|
||||||
openCreateForm,
|
|
||||||
openEditForm,
|
|
||||||
closeForm,
|
|
||||||
|
|
||||||
// Loading states
|
|
||||||
isCreating: createMutation.isPending,
|
|
||||||
isUpdating: updateMutation.isPending,
|
|
||||||
isDeleting: deleteMutation.isPending,
|
|
||||||
isTesting: testConnectionMutation.isPending,
|
|
||||||
|
|
||||||
// Errors
|
|
||||||
createError: createMutation.error,
|
|
||||||
updateError: updateMutation.error,
|
|
||||||
deleteError: deleteMutation.error,
|
|
||||||
testError: testConnectionMutation.error,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -1,294 +0,0 @@
|
|||||||
// ================================================================
|
|
||||||
// frontend/src/api/hooks/useProcurement.ts
|
|
||||||
// ================================================================
|
|
||||||
/**
|
|
||||||
* React hooks for procurement planning functionality
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|
||||||
import { procurementService } from '../services/procurement.service';
|
|
||||||
import type {
|
|
||||||
ProcurementPlan,
|
|
||||||
GeneratePlanRequest,
|
|
||||||
GeneratePlanResponse,
|
|
||||||
DashboardData,
|
|
||||||
ProcurementRequirement,
|
|
||||||
PaginatedProcurementPlans
|
|
||||||
} from '../types/procurement';
|
|
||||||
|
|
||||||
// ================================================================
|
|
||||||
// QUERY KEYS
|
|
||||||
// ================================================================
|
|
||||||
|
|
||||||
export const procurementKeys = {
|
|
||||||
all: ['procurement'] as const,
|
|
||||||
plans: () => [...procurementKeys.all, 'plans'] as const,
|
|
||||||
plan: (id: string) => [...procurementKeys.plans(), id] as const,
|
|
||||||
currentPlan: () => [...procurementKeys.plans(), 'current'] as const,
|
|
||||||
planByDate: (date: string) => [...procurementKeys.plans(), 'date', date] as const,
|
|
||||||
plansList: (filters?: any) => [...procurementKeys.plans(), 'list', filters] as const,
|
|
||||||
requirements: () => [...procurementKeys.all, 'requirements'] as const,
|
|
||||||
planRequirements: (planId: string) => [...procurementKeys.requirements(), 'plan', planId] as const,
|
|
||||||
criticalRequirements: () => [...procurementKeys.requirements(), 'critical'] as const,
|
|
||||||
dashboard: () => [...procurementKeys.all, 'dashboard'] as const,
|
|
||||||
};
|
|
||||||
|
|
||||||
// ================================================================
|
|
||||||
// PROCUREMENT PLAN HOOKS
|
|
||||||
// ================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hook to fetch the current day's procurement plan
|
|
||||||
*/
|
|
||||||
export function useCurrentProcurementPlan() {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: procurementKeys.currentPlan(),
|
|
||||||
queryFn: () => procurementService.getCurrentPlan(),
|
|
||||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
|
||||||
refetchInterval: 10 * 60 * 1000, // Refetch every 10 minutes
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hook to fetch procurement plan by date
|
|
||||||
*/
|
|
||||||
export function useProcurementPlanByDate(date: string, enabled = true) {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: procurementKeys.planByDate(date),
|
|
||||||
queryFn: () => procurementService.getPlanByDate(date),
|
|
||||||
enabled: enabled && !!date,
|
|
||||||
staleTime: 30 * 60 * 1000, // 30 minutes for historical data
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hook to fetch procurement plan by ID
|
|
||||||
*/
|
|
||||||
export function useProcurementPlan(planId: string, enabled = true) {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: procurementKeys.plan(planId),
|
|
||||||
queryFn: () => procurementService.getPlanById(planId),
|
|
||||||
enabled: enabled && !!planId,
|
|
||||||
staleTime: 10 * 60 * 1000, // 10 minutes
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hook to fetch paginated list of procurement plans
|
|
||||||
*/
|
|
||||||
export function useProcurementPlans(params?: {
|
|
||||||
status?: string;
|
|
||||||
startDate?: string;
|
|
||||||
endDate?: string;
|
|
||||||
limit?: number;
|
|
||||||
offset?: number;
|
|
||||||
}) {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: procurementKeys.plansList(params),
|
|
||||||
queryFn: () => procurementService.listPlans(params),
|
|
||||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ================================================================
|
|
||||||
// REQUIREMENTS HOOKS
|
|
||||||
// ================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hook to fetch requirements for a specific plan
|
|
||||||
*/
|
|
||||||
export function usePlanRequirements(
|
|
||||||
planId: string,
|
|
||||||
filters?: {
|
|
||||||
status?: string;
|
|
||||||
priority?: string;
|
|
||||||
},
|
|
||||||
enabled = true
|
|
||||||
) {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: procurementKeys.planRequirements(planId),
|
|
||||||
queryFn: () => procurementService.getPlanRequirements(planId, filters),
|
|
||||||
enabled: enabled && !!planId,
|
|
||||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hook to fetch critical requirements across all plans
|
|
||||||
*/
|
|
||||||
export function useCriticalRequirements() {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: procurementKeys.criticalRequirements(),
|
|
||||||
queryFn: () => procurementService.getCriticalRequirements(),
|
|
||||||
staleTime: 2 * 60 * 1000, // 2 minutes for critical data
|
|
||||||
refetchInterval: 5 * 60 * 1000, // Refetch every 5 minutes
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ================================================================
|
|
||||||
// DASHBOARD HOOKS
|
|
||||||
// ================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hook to fetch procurement dashboard data
|
|
||||||
*/
|
|
||||||
export function useProcurementDashboard() {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: procurementKeys.dashboard(),
|
|
||||||
queryFn: () => procurementService.getDashboardData(),
|
|
||||||
staleTime: 2 * 60 * 1000, // 2 minutes
|
|
||||||
refetchInterval: 5 * 60 * 1000, // Refetch every 5 minutes
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ================================================================
|
|
||||||
// MUTATION HOOKS
|
|
||||||
// ================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hook to generate a new procurement plan
|
|
||||||
*/
|
|
||||||
export function useGenerateProcurementPlan() {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: (request: GeneratePlanRequest) =>
|
|
||||||
procurementService.generatePlan(request),
|
|
||||||
onSuccess: (data: GeneratePlanResponse) => {
|
|
||||||
// Invalidate relevant queries
|
|
||||||
queryClient.invalidateQueries({ queryKey: procurementKeys.plans() });
|
|
||||||
queryClient.invalidateQueries({ queryKey: procurementKeys.dashboard() });
|
|
||||||
|
|
||||||
// If plan was generated successfully, update the cache
|
|
||||||
if (data.success && data.plan) {
|
|
||||||
queryClient.setQueryData(
|
|
||||||
procurementKeys.plan(data.plan.id),
|
|
||||||
data.plan
|
|
||||||
);
|
|
||||||
|
|
||||||
// Update current plan cache if this is today's plan
|
|
||||||
const today = new Date().toISOString().split('T')[0];
|
|
||||||
if (data.plan.plan_date === today) {
|
|
||||||
queryClient.setQueryData(
|
|
||||||
procurementKeys.currentPlan(),
|
|
||||||
data.plan
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hook to update procurement plan status
|
|
||||||
*/
|
|
||||||
export function useUpdatePlanStatus() {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: ({ planId, status }: { planId: string; status: string }) =>
|
|
||||||
procurementService.updatePlanStatus(planId, status),
|
|
||||||
onSuccess: (updatedPlan: ProcurementPlan) => {
|
|
||||||
// Update the specific plan in cache
|
|
||||||
queryClient.setQueryData(
|
|
||||||
procurementKeys.plan(updatedPlan.id),
|
|
||||||
updatedPlan
|
|
||||||
);
|
|
||||||
|
|
||||||
// Update current plan if this is the current plan
|
|
||||||
const today = new Date().toISOString().split('T')[0];
|
|
||||||
if (updatedPlan.plan_date === today) {
|
|
||||||
queryClient.setQueryData(
|
|
||||||
procurementKeys.currentPlan(),
|
|
||||||
updatedPlan
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Invalidate lists to ensure they're refreshed
|
|
||||||
queryClient.invalidateQueries({ queryKey: procurementKeys.plansList() });
|
|
||||||
queryClient.invalidateQueries({ queryKey: procurementKeys.dashboard() });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hook to trigger the daily scheduler manually
|
|
||||||
*/
|
|
||||||
export function useTriggerDailyScheduler() {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: () => procurementService.triggerDailyScheduler(),
|
|
||||||
onSuccess: () => {
|
|
||||||
// Invalidate all procurement data
|
|
||||||
queryClient.invalidateQueries({ queryKey: procurementKeys.all });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ================================================================
|
|
||||||
// UTILITY HOOKS
|
|
||||||
// ================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hook to check procurement service health
|
|
||||||
*/
|
|
||||||
export function useProcurementHealth() {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: [...procurementKeys.all, 'health'],
|
|
||||||
queryFn: () => procurementService.healthCheck(),
|
|
||||||
staleTime: 60 * 1000, // 1 minute
|
|
||||||
refetchInterval: 5 * 60 * 1000, // Check every 5 minutes
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ================================================================
|
|
||||||
// COMBINED HOOKS
|
|
||||||
// ================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Combined hook for procurement plan dashboard
|
|
||||||
* Fetches current plan, dashboard data, and critical requirements
|
|
||||||
*/
|
|
||||||
export function useProcurementPlanDashboard() {
|
|
||||||
const currentPlan = useCurrentProcurementPlan();
|
|
||||||
const dashboard = useProcurementDashboard();
|
|
||||||
const criticalRequirements = useCriticalRequirements();
|
|
||||||
const health = useProcurementHealth();
|
|
||||||
|
|
||||||
return {
|
|
||||||
currentPlan,
|
|
||||||
dashboard,
|
|
||||||
criticalRequirements,
|
|
||||||
health,
|
|
||||||
isLoading: currentPlan.isLoading || dashboard.isLoading,
|
|
||||||
error: currentPlan.error || dashboard.error || criticalRequirements.error,
|
|
||||||
refetchAll: () => {
|
|
||||||
currentPlan.refetch();
|
|
||||||
dashboard.refetch();
|
|
||||||
criticalRequirements.refetch();
|
|
||||||
health.refetch();
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hook for managing procurement plan lifecycle
|
|
||||||
*/
|
|
||||||
export function useProcurementPlanActions() {
|
|
||||||
const generatePlan = useGenerateProcurementPlan();
|
|
||||||
const updateStatus = useUpdatePlanStatus();
|
|
||||||
const triggerScheduler = useTriggerDailyScheduler();
|
|
||||||
|
|
||||||
return {
|
|
||||||
generatePlan: generatePlan.mutate,
|
|
||||||
updateStatus: updateStatus.mutate,
|
|
||||||
triggerScheduler: triggerScheduler.mutate,
|
|
||||||
isGenerating: generatePlan.isPending,
|
|
||||||
isUpdating: updateStatus.isPending,
|
|
||||||
isTriggering: triggerScheduler.isPending,
|
|
||||||
generateError: generatePlan.error,
|
|
||||||
updateError: updateStatus.error,
|
|
||||||
triggerError: triggerScheduler.error,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,682 +0,0 @@
|
|||||||
// frontend/src/api/hooks/useRecipes.ts
|
|
||||||
/**
|
|
||||||
* React hooks for recipe and production management
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
|
||||||
import { toast } from 'react-hot-toast';
|
|
||||||
import {
|
|
||||||
RecipesService,
|
|
||||||
Recipe,
|
|
||||||
RecipeIngredient,
|
|
||||||
CreateRecipeRequest,
|
|
||||||
UpdateRecipeRequest,
|
|
||||||
RecipeSearchParams,
|
|
||||||
RecipeFeasibility,
|
|
||||||
RecipeStatistics,
|
|
||||||
ProductionBatch,
|
|
||||||
CreateProductionBatchRequest,
|
|
||||||
UpdateProductionBatchRequest,
|
|
||||||
ProductionBatchSearchParams,
|
|
||||||
ProductionStatistics
|
|
||||||
} from '../services/recipes.service';
|
|
||||||
import { useTenant } from './useTenant';
|
|
||||||
import { useAuth } from './useAuth';
|
|
||||||
|
|
||||||
const recipesService = new RecipesService();
|
|
||||||
|
|
||||||
// Recipe Management Hook
|
|
||||||
export interface UseRecipesReturn {
|
|
||||||
// Data
|
|
||||||
recipes: Recipe[];
|
|
||||||
selectedRecipe: Recipe | null;
|
|
||||||
categories: string[];
|
|
||||||
statistics: RecipeStatistics | null;
|
|
||||||
|
|
||||||
// State
|
|
||||||
isLoading: boolean;
|
|
||||||
isCreating: boolean;
|
|
||||||
isUpdating: boolean;
|
|
||||||
isDeleting: boolean;
|
|
||||||
error: string | null;
|
|
||||||
|
|
||||||
// Pagination
|
|
||||||
pagination: {
|
|
||||||
page: number;
|
|
||||||
limit: number;
|
|
||||||
total: number;
|
|
||||||
totalPages: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Actions
|
|
||||||
loadRecipes: (params?: RecipeSearchParams) => Promise<void>;
|
|
||||||
loadRecipe: (recipeId: string) => Promise<void>;
|
|
||||||
createRecipe: (data: CreateRecipeRequest) => Promise<Recipe | null>;
|
|
||||||
updateRecipe: (recipeId: string, data: UpdateRecipeRequest) => Promise<Recipe | null>;
|
|
||||||
deleteRecipe: (recipeId: string) => Promise<boolean>;
|
|
||||||
duplicateRecipe: (recipeId: string, newName: string) => Promise<Recipe | null>;
|
|
||||||
activateRecipe: (recipeId: string) => Promise<Recipe | null>;
|
|
||||||
checkFeasibility: (recipeId: string, batchMultiplier?: number) => Promise<RecipeFeasibility | null>;
|
|
||||||
loadStatistics: () => Promise<void>;
|
|
||||||
loadCategories: () => Promise<void>;
|
|
||||||
clearError: () => void;
|
|
||||||
refresh: () => Promise<void>;
|
|
||||||
setPage: (page: number) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useRecipes = (autoLoad: boolean = true): UseRecipesReturn => {
|
|
||||||
const { currentTenant } = useTenant();
|
|
||||||
const { user } = useAuth();
|
|
||||||
|
|
||||||
// State
|
|
||||||
const [recipes, setRecipes] = useState<Recipe[]>([]);
|
|
||||||
const [selectedRecipe, setSelectedRecipe] = useState<Recipe | null>(null);
|
|
||||||
const [categories, setCategories] = useState<string[]>([]);
|
|
||||||
const [statistics, setStatistics] = useState<RecipeStatistics | null>(null);
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
const [isCreating, setIsCreating] = useState(false);
|
|
||||||
const [isUpdating, setIsUpdating] = useState(false);
|
|
||||||
const [isDeleting, setIsDeleting] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [currentParams, setCurrentParams] = useState<RecipeSearchParams>({});
|
|
||||||
const [pagination, setPagination] = useState({
|
|
||||||
page: 1,
|
|
||||||
limit: 20,
|
|
||||||
total: 0,
|
|
||||||
totalPages: 0
|
|
||||||
});
|
|
||||||
|
|
||||||
// Load recipes
|
|
||||||
const loadRecipes = useCallback(async (params: RecipeSearchParams = {}) => {
|
|
||||||
if (!currentTenant?.id) return;
|
|
||||||
|
|
||||||
setIsLoading(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const searchParams = {
|
|
||||||
...params,
|
|
||||||
limit: pagination.limit,
|
|
||||||
offset: (pagination.page - 1) * pagination.limit
|
|
||||||
};
|
|
||||||
|
|
||||||
const recipesData = await recipesService.getRecipes(currentTenant.id, searchParams);
|
|
||||||
setRecipes(recipesData);
|
|
||||||
setCurrentParams(params);
|
|
||||||
|
|
||||||
// Calculate pagination (assuming we get total count somehow)
|
|
||||||
const total = recipesData.length; // This would need to be from a proper paginated response
|
|
||||||
setPagination(prev => ({
|
|
||||||
...prev,
|
|
||||||
total,
|
|
||||||
totalPages: Math.ceil(total / prev.limit)
|
|
||||||
}));
|
|
||||||
|
|
||||||
} catch (err: any) {
|
|
||||||
const errorMessage = err.response?.data?.detail || err.message || 'Error loading recipes';
|
|
||||||
setError(errorMessage);
|
|
||||||
toast.error(errorMessage);
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
}, [currentTenant?.id, pagination.page, pagination.limit]);
|
|
||||||
|
|
||||||
// Load single recipe
|
|
||||||
const loadRecipe = useCallback(async (recipeId: string) => {
|
|
||||||
if (!currentTenant?.id) return;
|
|
||||||
|
|
||||||
setIsLoading(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const recipe = await recipesService.getRecipe(currentTenant.id, recipeId);
|
|
||||||
setSelectedRecipe(recipe);
|
|
||||||
} catch (err: any) {
|
|
||||||
const errorMessage = err.response?.data?.detail || err.message || 'Error loading recipe';
|
|
||||||
setError(errorMessage);
|
|
||||||
toast.error(errorMessage);
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
}, [currentTenant?.id]);
|
|
||||||
|
|
||||||
// Create recipe
|
|
||||||
const createRecipe = useCallback(async (data: CreateRecipeRequest): Promise<Recipe | null> => {
|
|
||||||
if (!currentTenant?.id || !user?.id) return null;
|
|
||||||
|
|
||||||
setIsCreating(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const newRecipe = await recipesService.createRecipe(currentTenant.id, user.id, data);
|
|
||||||
|
|
||||||
// Add to local state
|
|
||||||
setRecipes(prev => [newRecipe, ...prev]);
|
|
||||||
|
|
||||||
toast.success('Recipe created successfully');
|
|
||||||
return newRecipe;
|
|
||||||
} catch (err: any) {
|
|
||||||
const errorMessage = err.response?.data?.detail || err.message || 'Error creating recipe';
|
|
||||||
setError(errorMessage);
|
|
||||||
toast.error(errorMessage);
|
|
||||||
return null;
|
|
||||||
} finally {
|
|
||||||
setIsCreating(false);
|
|
||||||
}
|
|
||||||
}, [currentTenant?.id, user?.id]);
|
|
||||||
|
|
||||||
// Update recipe
|
|
||||||
const updateRecipe = useCallback(async (recipeId: string, data: UpdateRecipeRequest): Promise<Recipe | null> => {
|
|
||||||
if (!currentTenant?.id || !user?.id) return null;
|
|
||||||
|
|
||||||
setIsUpdating(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const updatedRecipe = await recipesService.updateRecipe(currentTenant.id, user.id, recipeId, data);
|
|
||||||
|
|
||||||
// Update local state
|
|
||||||
setRecipes(prev => prev.map(recipe =>
|
|
||||||
recipe.id === recipeId ? updatedRecipe : recipe
|
|
||||||
));
|
|
||||||
|
|
||||||
if (selectedRecipe?.id === recipeId) {
|
|
||||||
setSelectedRecipe(updatedRecipe);
|
|
||||||
}
|
|
||||||
|
|
||||||
toast.success('Recipe updated successfully');
|
|
||||||
return updatedRecipe;
|
|
||||||
} catch (err: any) {
|
|
||||||
const errorMessage = err.response?.data?.detail || err.message || 'Error updating recipe';
|
|
||||||
setError(errorMessage);
|
|
||||||
toast.error(errorMessage);
|
|
||||||
return null;
|
|
||||||
} finally {
|
|
||||||
setIsUpdating(false);
|
|
||||||
}
|
|
||||||
}, [currentTenant?.id, user?.id, selectedRecipe?.id]);
|
|
||||||
|
|
||||||
// Delete recipe
|
|
||||||
const deleteRecipe = useCallback(async (recipeId: string): Promise<boolean> => {
|
|
||||||
if (!currentTenant?.id) return false;
|
|
||||||
|
|
||||||
setIsDeleting(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await recipesService.deleteRecipe(currentTenant.id, recipeId);
|
|
||||||
|
|
||||||
// Remove from local state
|
|
||||||
setRecipes(prev => prev.filter(recipe => recipe.id !== recipeId));
|
|
||||||
|
|
||||||
if (selectedRecipe?.id === recipeId) {
|
|
||||||
setSelectedRecipe(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
toast.success('Recipe deleted successfully');
|
|
||||||
return true;
|
|
||||||
} catch (err: any) {
|
|
||||||
const errorMessage = err.response?.data?.detail || err.message || 'Error deleting recipe';
|
|
||||||
setError(errorMessage);
|
|
||||||
toast.error(errorMessage);
|
|
||||||
return false;
|
|
||||||
} finally {
|
|
||||||
setIsDeleting(false);
|
|
||||||
}
|
|
||||||
}, [currentTenant?.id, selectedRecipe?.id]);
|
|
||||||
|
|
||||||
// Duplicate recipe
|
|
||||||
const duplicateRecipe = useCallback(async (recipeId: string, newName: string): Promise<Recipe | null> => {
|
|
||||||
if (!currentTenant?.id || !user?.id) return null;
|
|
||||||
|
|
||||||
setIsCreating(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const duplicatedRecipe = await recipesService.duplicateRecipe(currentTenant.id, user.id, recipeId, newName);
|
|
||||||
|
|
||||||
// Add to local state
|
|
||||||
setRecipes(prev => [duplicatedRecipe, ...prev]);
|
|
||||||
|
|
||||||
toast.success('Recipe duplicated successfully');
|
|
||||||
return duplicatedRecipe;
|
|
||||||
} catch (err: any) {
|
|
||||||
const errorMessage = err.response?.data?.detail || err.message || 'Error duplicating recipe';
|
|
||||||
setError(errorMessage);
|
|
||||||
toast.error(errorMessage);
|
|
||||||
return null;
|
|
||||||
} finally {
|
|
||||||
setIsCreating(false);
|
|
||||||
}
|
|
||||||
}, [currentTenant?.id, user?.id]);
|
|
||||||
|
|
||||||
// Activate recipe
|
|
||||||
const activateRecipe = useCallback(async (recipeId: string): Promise<Recipe | null> => {
|
|
||||||
if (!currentTenant?.id || !user?.id) return null;
|
|
||||||
|
|
||||||
setIsUpdating(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const activatedRecipe = await recipesService.activateRecipe(currentTenant.id, user.id, recipeId);
|
|
||||||
|
|
||||||
// Update local state
|
|
||||||
setRecipes(prev => prev.map(recipe =>
|
|
||||||
recipe.id === recipeId ? activatedRecipe : recipe
|
|
||||||
));
|
|
||||||
|
|
||||||
if (selectedRecipe?.id === recipeId) {
|
|
||||||
setSelectedRecipe(activatedRecipe);
|
|
||||||
}
|
|
||||||
|
|
||||||
toast.success('Recipe activated successfully');
|
|
||||||
return activatedRecipe;
|
|
||||||
} catch (err: any) {
|
|
||||||
const errorMessage = err.response?.data?.detail || err.message || 'Error activating recipe';
|
|
||||||
setError(errorMessage);
|
|
||||||
toast.error(errorMessage);
|
|
||||||
return null;
|
|
||||||
} finally {
|
|
||||||
setIsUpdating(false);
|
|
||||||
}
|
|
||||||
}, [currentTenant?.id, user?.id, selectedRecipe?.id]);
|
|
||||||
|
|
||||||
// Check feasibility
|
|
||||||
const checkFeasibility = useCallback(async (recipeId: string, batchMultiplier: number = 1.0): Promise<RecipeFeasibility | null> => {
|
|
||||||
if (!currentTenant?.id) return null;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const feasibility = await recipesService.checkRecipeFeasibility(currentTenant.id, recipeId, batchMultiplier);
|
|
||||||
return feasibility;
|
|
||||||
} catch (err: any) {
|
|
||||||
const errorMessage = err.response?.data?.detail || err.message || 'Error checking recipe feasibility';
|
|
||||||
setError(errorMessage);
|
|
||||||
toast.error(errorMessage);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}, [currentTenant?.id]);
|
|
||||||
|
|
||||||
// Load statistics
|
|
||||||
const loadStatistics = useCallback(async () => {
|
|
||||||
if (!currentTenant?.id) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const stats = await recipesService.getRecipeStatistics(currentTenant.id);
|
|
||||||
setStatistics(stats);
|
|
||||||
} catch (err: any) {
|
|
||||||
console.error('Error loading recipe statistics:', err);
|
|
||||||
}
|
|
||||||
}, [currentTenant?.id]);
|
|
||||||
|
|
||||||
// Load categories
|
|
||||||
const loadCategories = useCallback(async () => {
|
|
||||||
if (!currentTenant?.id) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const cats = await recipesService.getRecipeCategories(currentTenant.id);
|
|
||||||
setCategories(cats);
|
|
||||||
} catch (err: any) {
|
|
||||||
console.error('Error loading recipe categories:', err);
|
|
||||||
}
|
|
||||||
}, [currentTenant?.id]);
|
|
||||||
|
|
||||||
// Clear error
|
|
||||||
const clearError = useCallback(() => {
|
|
||||||
setError(null);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Refresh
|
|
||||||
const refresh = useCallback(async () => {
|
|
||||||
await Promise.all([
|
|
||||||
loadRecipes(currentParams),
|
|
||||||
loadStatistics(),
|
|
||||||
loadCategories()
|
|
||||||
]);
|
|
||||||
}, [loadRecipes, currentParams, loadStatistics, loadCategories]);
|
|
||||||
|
|
||||||
// Set page
|
|
||||||
const setPage = useCallback((page: number) => {
|
|
||||||
setPagination(prev => ({ ...prev, page }));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Auto-load on mount and dependencies change
|
|
||||||
useEffect(() => {
|
|
||||||
if (autoLoad && currentTenant?.id) {
|
|
||||||
refresh();
|
|
||||||
}
|
|
||||||
}, [autoLoad, currentTenant?.id, pagination.page]);
|
|
||||||
|
|
||||||
return {
|
|
||||||
// Data
|
|
||||||
recipes,
|
|
||||||
selectedRecipe,
|
|
||||||
categories,
|
|
||||||
statistics,
|
|
||||||
|
|
||||||
// State
|
|
||||||
isLoading,
|
|
||||||
isCreating,
|
|
||||||
isUpdating,
|
|
||||||
isDeleting,
|
|
||||||
error,
|
|
||||||
pagination,
|
|
||||||
|
|
||||||
// Actions
|
|
||||||
loadRecipes,
|
|
||||||
loadRecipe,
|
|
||||||
createRecipe,
|
|
||||||
updateRecipe,
|
|
||||||
deleteRecipe,
|
|
||||||
duplicateRecipe,
|
|
||||||
activateRecipe,
|
|
||||||
checkFeasibility,
|
|
||||||
loadStatistics,
|
|
||||||
loadCategories,
|
|
||||||
clearError,
|
|
||||||
refresh,
|
|
||||||
setPage
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
// Production Management Hook
|
|
||||||
export interface UseProductionReturn {
|
|
||||||
// Data
|
|
||||||
batches: ProductionBatch[];
|
|
||||||
selectedBatch: ProductionBatch | null;
|
|
||||||
activeBatches: ProductionBatch[];
|
|
||||||
statistics: ProductionStatistics | null;
|
|
||||||
|
|
||||||
// State
|
|
||||||
isLoading: boolean;
|
|
||||||
isCreating: boolean;
|
|
||||||
isUpdating: boolean;
|
|
||||||
isDeleting: boolean;
|
|
||||||
error: string | null;
|
|
||||||
|
|
||||||
// Actions
|
|
||||||
loadBatches: (params?: ProductionBatchSearchParams) => Promise<void>;
|
|
||||||
loadBatch: (batchId: string) => Promise<void>;
|
|
||||||
loadActiveBatches: () => Promise<void>;
|
|
||||||
createBatch: (data: CreateProductionBatchRequest) => Promise<ProductionBatch | null>;
|
|
||||||
updateBatch: (batchId: string, data: UpdateProductionBatchRequest) => Promise<ProductionBatch | null>;
|
|
||||||
deleteBatch: (batchId: string) => Promise<boolean>;
|
|
||||||
startBatch: (batchId: string, data: any) => Promise<ProductionBatch | null>;
|
|
||||||
completeBatch: (batchId: string, data: any) => Promise<ProductionBatch | null>;
|
|
||||||
loadStatistics: (startDate?: string, endDate?: string) => Promise<void>;
|
|
||||||
clearError: () => void;
|
|
||||||
refresh: () => Promise<void>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useProduction = (autoLoad: boolean = true): UseProductionReturn => {
|
|
||||||
const { currentTenant } = useTenant();
|
|
||||||
const { user } = useAuth();
|
|
||||||
|
|
||||||
// State
|
|
||||||
const [batches, setBatches] = useState<ProductionBatch[]>([]);
|
|
||||||
const [selectedBatch, setSelectedBatch] = useState<ProductionBatch | null>(null);
|
|
||||||
const [activeBatches, setActiveBatches] = useState<ProductionBatch[]>([]);
|
|
||||||
const [statistics, setStatistics] = useState<ProductionStatistics | null>(null);
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
const [isCreating, setIsCreating] = useState(false);
|
|
||||||
const [isUpdating, setIsUpdating] = useState(false);
|
|
||||||
const [isDeleting, setIsDeleting] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
// Load batches
|
|
||||||
const loadBatches = useCallback(async (params: ProductionBatchSearchParams = {}) => {
|
|
||||||
if (!currentTenant?.id) return;
|
|
||||||
|
|
||||||
setIsLoading(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const batchesData = await recipesService.getProductionBatches(currentTenant.id, params);
|
|
||||||
setBatches(batchesData);
|
|
||||||
} catch (err: any) {
|
|
||||||
const errorMessage = err.response?.data?.detail || err.message || 'Error loading production batches';
|
|
||||||
setError(errorMessage);
|
|
||||||
toast.error(errorMessage);
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
}, [currentTenant?.id]);
|
|
||||||
|
|
||||||
// Load single batch
|
|
||||||
const loadBatch = useCallback(async (batchId: string) => {
|
|
||||||
if (!currentTenant?.id) return;
|
|
||||||
|
|
||||||
setIsLoading(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const batch = await recipesService.getProductionBatch(currentTenant.id, batchId);
|
|
||||||
setSelectedBatch(batch);
|
|
||||||
} catch (err: any) {
|
|
||||||
const errorMessage = err.response?.data?.detail || err.message || 'Error loading production batch';
|
|
||||||
setError(errorMessage);
|
|
||||||
toast.error(errorMessage);
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
}, [currentTenant?.id]);
|
|
||||||
|
|
||||||
// Load active batches
|
|
||||||
const loadActiveBatches = useCallback(async () => {
|
|
||||||
if (!currentTenant?.id) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const activeBatchesData = await recipesService.getActiveProductionBatches(currentTenant.id);
|
|
||||||
setActiveBatches(activeBatchesData);
|
|
||||||
} catch (err: any) {
|
|
||||||
console.error('Error loading active batches:', err);
|
|
||||||
}
|
|
||||||
}, [currentTenant?.id]);
|
|
||||||
|
|
||||||
// Create batch
|
|
||||||
const createBatch = useCallback(async (data: CreateProductionBatchRequest): Promise<ProductionBatch | null> => {
|
|
||||||
if (!currentTenant?.id || !user?.id) return null;
|
|
||||||
|
|
||||||
setIsCreating(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const newBatch = await recipesService.createProductionBatch(currentTenant.id, user.id, data);
|
|
||||||
|
|
||||||
// Add to local state
|
|
||||||
setBatches(prev => [newBatch, ...prev]);
|
|
||||||
|
|
||||||
toast.success('Production batch created successfully');
|
|
||||||
return newBatch;
|
|
||||||
} catch (err: any) {
|
|
||||||
const errorMessage = err.response?.data?.detail || err.message || 'Error creating production batch';
|
|
||||||
setError(errorMessage);
|
|
||||||
toast.error(errorMessage);
|
|
||||||
return null;
|
|
||||||
} finally {
|
|
||||||
setIsCreating(false);
|
|
||||||
}
|
|
||||||
}, [currentTenant?.id, user?.id]);
|
|
||||||
|
|
||||||
// Update batch
|
|
||||||
const updateBatch = useCallback(async (batchId: string, data: UpdateProductionBatchRequest): Promise<ProductionBatch | null> => {
|
|
||||||
if (!currentTenant?.id || !user?.id) return null;
|
|
||||||
|
|
||||||
setIsUpdating(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const updatedBatch = await recipesService.updateProductionBatch(currentTenant.id, user.id, batchId, data);
|
|
||||||
|
|
||||||
// Update local state
|
|
||||||
setBatches(prev => prev.map(batch =>
|
|
||||||
batch.id === batchId ? updatedBatch : batch
|
|
||||||
));
|
|
||||||
|
|
||||||
if (selectedBatch?.id === batchId) {
|
|
||||||
setSelectedBatch(updatedBatch);
|
|
||||||
}
|
|
||||||
|
|
||||||
toast.success('Production batch updated successfully');
|
|
||||||
return updatedBatch;
|
|
||||||
} catch (err: any) {
|
|
||||||
const errorMessage = err.response?.data?.detail || err.message || 'Error updating production batch';
|
|
||||||
setError(errorMessage);
|
|
||||||
toast.error(errorMessage);
|
|
||||||
return null;
|
|
||||||
} finally {
|
|
||||||
setIsUpdating(false);
|
|
||||||
}
|
|
||||||
}, [currentTenant?.id, user?.id, selectedBatch?.id]);
|
|
||||||
|
|
||||||
// Delete batch
|
|
||||||
const deleteBatch = useCallback(async (batchId: string): Promise<boolean> => {
|
|
||||||
if (!currentTenant?.id) return false;
|
|
||||||
|
|
||||||
setIsDeleting(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await recipesService.deleteProductionBatch(currentTenant.id, batchId);
|
|
||||||
|
|
||||||
// Remove from local state
|
|
||||||
setBatches(prev => prev.filter(batch => batch.id !== batchId));
|
|
||||||
|
|
||||||
if (selectedBatch?.id === batchId) {
|
|
||||||
setSelectedBatch(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
toast.success('Production batch deleted successfully');
|
|
||||||
return true;
|
|
||||||
} catch (err: any) {
|
|
||||||
const errorMessage = err.response?.data?.detail || err.message || 'Error deleting production batch';
|
|
||||||
setError(errorMessage);
|
|
||||||
toast.error(errorMessage);
|
|
||||||
return false;
|
|
||||||
} finally {
|
|
||||||
setIsDeleting(false);
|
|
||||||
}
|
|
||||||
}, [currentTenant?.id, selectedBatch?.id]);
|
|
||||||
|
|
||||||
// Start batch
|
|
||||||
const startBatch = useCallback(async (batchId: string, data: any): Promise<ProductionBatch | null> => {
|
|
||||||
if (!currentTenant?.id || !user?.id) return null;
|
|
||||||
|
|
||||||
setIsUpdating(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const startedBatch = await recipesService.startProductionBatch(currentTenant.id, user.id, batchId, data);
|
|
||||||
|
|
||||||
// Update local state
|
|
||||||
setBatches(prev => prev.map(batch =>
|
|
||||||
batch.id === batchId ? startedBatch : batch
|
|
||||||
));
|
|
||||||
|
|
||||||
if (selectedBatch?.id === batchId) {
|
|
||||||
setSelectedBatch(startedBatch);
|
|
||||||
}
|
|
||||||
|
|
||||||
toast.success('Production batch started successfully');
|
|
||||||
return startedBatch;
|
|
||||||
} catch (err: any) {
|
|
||||||
const errorMessage = err.response?.data?.detail || err.message || 'Error starting production batch';
|
|
||||||
setError(errorMessage);
|
|
||||||
toast.error(errorMessage);
|
|
||||||
return null;
|
|
||||||
} finally {
|
|
||||||
setIsUpdating(false);
|
|
||||||
}
|
|
||||||
}, [currentTenant?.id, user?.id, selectedBatch?.id]);
|
|
||||||
|
|
||||||
// Complete batch
|
|
||||||
const completeBatch = useCallback(async (batchId: string, data: any): Promise<ProductionBatch | null> => {
|
|
||||||
if (!currentTenant?.id || !user?.id) return null;
|
|
||||||
|
|
||||||
setIsUpdating(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const completedBatch = await recipesService.completeProductionBatch(currentTenant.id, user.id, batchId, data);
|
|
||||||
|
|
||||||
// Update local state
|
|
||||||
setBatches(prev => prev.map(batch =>
|
|
||||||
batch.id === batchId ? completedBatch : batch
|
|
||||||
));
|
|
||||||
|
|
||||||
if (selectedBatch?.id === batchId) {
|
|
||||||
setSelectedBatch(completedBatch);
|
|
||||||
}
|
|
||||||
|
|
||||||
toast.success('Production batch completed successfully');
|
|
||||||
return completedBatch;
|
|
||||||
} catch (err: any) {
|
|
||||||
const errorMessage = err.response?.data?.detail || err.message || 'Error completing production batch';
|
|
||||||
setError(errorMessage);
|
|
||||||
toast.error(errorMessage);
|
|
||||||
return null;
|
|
||||||
} finally {
|
|
||||||
setIsUpdating(false);
|
|
||||||
}
|
|
||||||
}, [currentTenant?.id, user?.id, selectedBatch?.id]);
|
|
||||||
|
|
||||||
// Load statistics
|
|
||||||
const loadStatistics = useCallback(async (startDate?: string, endDate?: string) => {
|
|
||||||
if (!currentTenant?.id) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const stats = await recipesService.getProductionStatistics(currentTenant.id, startDate, endDate);
|
|
||||||
setStatistics(stats);
|
|
||||||
} catch (err: any) {
|
|
||||||
console.error('Error loading production statistics:', err);
|
|
||||||
}
|
|
||||||
}, [currentTenant?.id]);
|
|
||||||
|
|
||||||
// Clear error
|
|
||||||
const clearError = useCallback(() => {
|
|
||||||
setError(null);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Refresh
|
|
||||||
const refresh = useCallback(async () => {
|
|
||||||
await Promise.all([
|
|
||||||
loadBatches(),
|
|
||||||
loadActiveBatches(),
|
|
||||||
loadStatistics()
|
|
||||||
]);
|
|
||||||
}, [loadBatches, loadActiveBatches, loadStatistics]);
|
|
||||||
|
|
||||||
// Auto-load on mount
|
|
||||||
useEffect(() => {
|
|
||||||
if (autoLoad && currentTenant?.id) {
|
|
||||||
refresh();
|
|
||||||
}
|
|
||||||
}, [autoLoad, currentTenant?.id]);
|
|
||||||
|
|
||||||
return {
|
|
||||||
// Data
|
|
||||||
batches,
|
|
||||||
selectedBatch,
|
|
||||||
activeBatches,
|
|
||||||
statistics,
|
|
||||||
|
|
||||||
// State
|
|
||||||
isLoading,
|
|
||||||
isCreating,
|
|
||||||
isUpdating,
|
|
||||||
isDeleting,
|
|
||||||
error,
|
|
||||||
|
|
||||||
// Actions
|
|
||||||
loadBatches,
|
|
||||||
loadBatch,
|
|
||||||
loadActiveBatches,
|
|
||||||
createBatch,
|
|
||||||
updateBatch,
|
|
||||||
deleteBatch,
|
|
||||||
startBatch,
|
|
||||||
completeBatch,
|
|
||||||
loadStatistics,
|
|
||||||
clearError,
|
|
||||||
refresh
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -1,200 +0,0 @@
|
|||||||
// frontend/src/api/hooks/useSales.ts
|
|
||||||
/**
|
|
||||||
* Sales Data Management Hooks
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { useState, useCallback } from 'react';
|
|
||||||
import { salesService } from '../services/sales.service';
|
|
||||||
import type {
|
|
||||||
SalesData,
|
|
||||||
SalesValidationResult,
|
|
||||||
SalesDataQuery,
|
|
||||||
SalesDataImport,
|
|
||||||
SalesImportResult,
|
|
||||||
DashboardStats,
|
|
||||||
ActivityItem,
|
|
||||||
} from '../types';
|
|
||||||
|
|
||||||
export const useSales = () => {
|
|
||||||
const [salesData, setSalesData] = useState<SalesData[]>([]);
|
|
||||||
const [dashboardStats, setDashboardStats] = useState<DashboardStats | null>(null);
|
|
||||||
const [recentActivity, setRecentActivity] = useState<ActivityItem[]>([]);
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [uploadProgress, setUploadProgress] = useState<number>(0);
|
|
||||||
|
|
||||||
const uploadSalesHistory = useCallback(async (
|
|
||||||
tenantId: string,
|
|
||||||
file: File,
|
|
||||||
additionalData?: Record<string, any>
|
|
||||||
): Promise<SalesImportResult> => {
|
|
||||||
try {
|
|
||||||
setIsLoading(true);
|
|
||||||
setError(null);
|
|
||||||
setUploadProgress(0);
|
|
||||||
|
|
||||||
const result = await salesService.uploadSalesHistory(tenantId, file, {
|
|
||||||
...additionalData,
|
|
||||||
onProgress: (progress) => {
|
|
||||||
setUploadProgress(progress.percentage);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return result;
|
|
||||||
} catch (error) {
|
|
||||||
const message = error instanceof Error ? error.message : 'Upload failed';
|
|
||||||
setError(message);
|
|
||||||
throw error;
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
setUploadProgress(0);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const validateSalesData = useCallback(async (
|
|
||||||
tenantId: string,
|
|
||||||
file: File
|
|
||||||
): Promise<SalesValidationResult> => {
|
|
||||||
try {
|
|
||||||
setIsLoading(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
const result = await salesService.validateSalesData(tenantId, file);
|
|
||||||
return result;
|
|
||||||
} catch (error) {
|
|
||||||
const message = error instanceof Error ? error.message : 'Validation failed';
|
|
||||||
setError(message);
|
|
||||||
throw error;
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const getSalesData = useCallback(async (
|
|
||||||
tenantId: string,
|
|
||||||
query?: SalesDataQuery
|
|
||||||
): Promise<SalesData[]> => {
|
|
||||||
try {
|
|
||||||
setIsLoading(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
const response = await salesService.getSalesData(tenantId, query);
|
|
||||||
setSalesData(response.data);
|
|
||||||
|
|
||||||
return response.data;
|
|
||||||
} catch (error) {
|
|
||||||
const message = error instanceof Error ? error.message : 'Failed to get sales data';
|
|
||||||
setError(message);
|
|
||||||
throw error;
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const getDashboardStats = useCallback(async (tenantId: string): Promise<DashboardStats> => {
|
|
||||||
try {
|
|
||||||
setIsLoading(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
const stats = await salesService.getDashboardStats(tenantId);
|
|
||||||
setDashboardStats(stats);
|
|
||||||
|
|
||||||
return stats;
|
|
||||||
} catch (error) {
|
|
||||||
const message = error instanceof Error ? error.message : 'Failed to get dashboard stats';
|
|
||||||
setError(message);
|
|
||||||
throw error;
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const getRecentActivity = useCallback(async (tenantId: string, limit?: number): Promise<ActivityItem[]> => {
|
|
||||||
try {
|
|
||||||
setIsLoading(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
const activity = await salesService.getRecentActivity(tenantId, limit);
|
|
||||||
setRecentActivity(activity);
|
|
||||||
|
|
||||||
return activity;
|
|
||||||
} catch (error) {
|
|
||||||
const message = error instanceof Error ? error.message : 'Failed to get recent activity';
|
|
||||||
setError(message);
|
|
||||||
throw error;
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const exportSalesData = useCallback(async (
|
|
||||||
tenantId: string,
|
|
||||||
format: 'csv' | 'excel' | 'json',
|
|
||||||
query?: SalesDataQuery
|
|
||||||
): Promise<void> => {
|
|
||||||
try {
|
|
||||||
setIsLoading(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
const blob = await salesService.exportSalesData(tenantId, format, query);
|
|
||||||
|
|
||||||
// Create download link
|
|
||||||
const url = window.URL.createObjectURL(blob);
|
|
||||||
const link = document.createElement('a');
|
|
||||||
link.href = url;
|
|
||||||
link.download = `sales-data.${format}`;
|
|
||||||
document.body.appendChild(link);
|
|
||||||
link.click();
|
|
||||||
document.body.removeChild(link);
|
|
||||||
window.URL.revokeObjectURL(url);
|
|
||||||
} catch (error) {
|
|
||||||
const message = error instanceof Error ? error.message : 'Export failed';
|
|
||||||
setError(message);
|
|
||||||
throw error;
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get Sales Analytics
|
|
||||||
*/
|
|
||||||
const getSalesAnalytics = useCallback(async (
|
|
||||||
tenantId: string,
|
|
||||||
startDate?: string,
|
|
||||||
endDate?: string
|
|
||||||
) => {
|
|
||||||
try {
|
|
||||||
setIsLoading(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
const analytics = await salesService.getSalesAnalytics(tenantId, startDate, endDate);
|
|
||||||
|
|
||||||
return analytics;
|
|
||||||
} catch (error) {
|
|
||||||
const message = error instanceof Error ? error.message : 'Failed to get sales analytics';
|
|
||||||
setError(message);
|
|
||||||
throw error;
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return {
|
|
||||||
salesData,
|
|
||||||
dashboardStats,
|
|
||||||
recentActivity,
|
|
||||||
isLoading,
|
|
||||||
error,
|
|
||||||
uploadProgress,
|
|
||||||
uploadSalesHistory,
|
|
||||||
validateSalesData,
|
|
||||||
getSalesData,
|
|
||||||
getDashboardStats,
|
|
||||||
getRecentActivity,
|
|
||||||
exportSalesData,
|
|
||||||
getSalesAnalytics,
|
|
||||||
clearError: () => setError(null),
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -1,101 +0,0 @@
|
|||||||
// Simplified useSuppliers hook for TypeScript compatibility
|
|
||||||
import { useState } from 'react';
|
|
||||||
import {
|
|
||||||
SupplierSummary,
|
|
||||||
CreateSupplierRequest,
|
|
||||||
UpdateSupplierRequest,
|
|
||||||
SupplierSearchParams,
|
|
||||||
SupplierStatistics,
|
|
||||||
PurchaseOrder,
|
|
||||||
CreatePurchaseOrderRequest,
|
|
||||||
PurchaseOrderSearchParams,
|
|
||||||
PurchaseOrderStatistics,
|
|
||||||
Delivery,
|
|
||||||
DeliverySearchParams,
|
|
||||||
DeliveryPerformanceStats
|
|
||||||
} from '../services/suppliers.service';
|
|
||||||
|
|
||||||
export const useSuppliers = () => {
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
// Simple stub implementations
|
|
||||||
const getSuppliers = async (params?: SupplierSearchParams) => {
|
|
||||||
setIsLoading(true);
|
|
||||||
try {
|
|
||||||
// Mock data for now
|
|
||||||
return [];
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof Error ? err.message : 'Unknown error');
|
|
||||||
throw err;
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const createSupplier = async (data: CreateSupplierRequest) => {
|
|
||||||
setIsLoading(true);
|
|
||||||
try {
|
|
||||||
// Mock implementation
|
|
||||||
return { id: '1', ...data } as any;
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof Error ? err.message : 'Unknown error');
|
|
||||||
throw err;
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateSupplier = async (id: string, data: UpdateSupplierRequest) => {
|
|
||||||
setIsLoading(true);
|
|
||||||
try {
|
|
||||||
// Mock implementation
|
|
||||||
return { id, ...data } as any;
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof Error ? err.message : 'Unknown error');
|
|
||||||
throw err;
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Return all the expected properties/methods
|
|
||||||
return {
|
|
||||||
suppliers: [],
|
|
||||||
isLoading,
|
|
||||||
error,
|
|
||||||
getSuppliers,
|
|
||||||
createSupplier,
|
|
||||||
updateSupplier,
|
|
||||||
deleteSupplier: async () => {},
|
|
||||||
getSupplierStatistics: async () => ({} as SupplierStatistics),
|
|
||||||
getActiveSuppliers: async () => [] as SupplierSummary[],
|
|
||||||
getTopSuppliers: async () => [] as SupplierSummary[],
|
|
||||||
getSuppliersNeedingReview: async () => [] as SupplierSummary[],
|
|
||||||
approveSupplier: async () => {},
|
|
||||||
// Purchase orders
|
|
||||||
getPurchaseOrders: async () => [] as PurchaseOrder[],
|
|
||||||
createPurchaseOrder: async () => ({} as PurchaseOrder),
|
|
||||||
updatePurchaseOrderStatus: async () => ({} as PurchaseOrder),
|
|
||||||
// Deliveries
|
|
||||||
getDeliveries: async () => [] as Delivery[],
|
|
||||||
getTodaysDeliveries: async () => [] as Delivery[],
|
|
||||||
getDeliveryPerformanceStats: async () => ({} as DeliveryPerformanceStats),
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
// Re-export types
|
|
||||||
export type {
|
|
||||||
SupplierSummary,
|
|
||||||
CreateSupplierRequest,
|
|
||||||
UpdateSupplierRequest,
|
|
||||||
SupplierSearchParams,
|
|
||||||
SupplierStatistics,
|
|
||||||
PurchaseOrder,
|
|
||||||
CreatePurchaseOrderRequest,
|
|
||||||
PurchaseOrderSearchParams,
|
|
||||||
PurchaseOrderStatistics,
|
|
||||||
Delivery,
|
|
||||||
DeliverySearchParams,
|
|
||||||
DeliveryPerformanceStats
|
|
||||||
};
|
|
||||||
@@ -1,209 +0,0 @@
|
|||||||
// frontend/src/api/hooks/useTenant.ts
|
|
||||||
/**
|
|
||||||
* Tenant Management Hooks
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { useState, useCallback } from 'react';
|
|
||||||
import { tenantService } from '../services';
|
|
||||||
import type {
|
|
||||||
TenantInfo,
|
|
||||||
TenantCreate,
|
|
||||||
TenantUpdate,
|
|
||||||
TenantMember,
|
|
||||||
InviteUser,
|
|
||||||
TenantStats,
|
|
||||||
} from '../types';
|
|
||||||
|
|
||||||
export const useTenant = () => {
|
|
||||||
const [tenants, setTenants] = useState<TenantInfo[]>([]);
|
|
||||||
const [currentTenant, setCurrentTenant] = useState<TenantInfo | null>(null);
|
|
||||||
const [members, setMembers] = useState<TenantMember[]>([]);
|
|
||||||
const [stats, setStats] = useState<TenantStats | null>(null);
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const createTenant = useCallback(async (data: TenantCreate): Promise<TenantInfo> => {
|
|
||||||
try {
|
|
||||||
setIsLoading(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
const tenant = await tenantService.createTenant(data);
|
|
||||||
setTenants(prev => [...prev, tenant]);
|
|
||||||
setCurrentTenant(tenant);
|
|
||||||
|
|
||||||
return tenant;
|
|
||||||
} catch (error) {
|
|
||||||
const message = error instanceof Error ? error.message : 'Failed to create tenant';
|
|
||||||
setError(message);
|
|
||||||
throw error;
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const getTenant = useCallback(async (tenantId: string): Promise<TenantInfo> => {
|
|
||||||
try {
|
|
||||||
setIsLoading(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
const tenant = await tenantService.getTenant(tenantId);
|
|
||||||
setCurrentTenant(tenant);
|
|
||||||
|
|
||||||
return tenant;
|
|
||||||
} catch (error) {
|
|
||||||
const message = error instanceof Error ? error.message : 'Failed to get tenant';
|
|
||||||
setError(message);
|
|
||||||
throw error;
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const updateTenant = useCallback(async (tenantId: string, data: TenantUpdate): Promise<TenantInfo> => {
|
|
||||||
try {
|
|
||||||
setIsLoading(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
const updatedTenant = await tenantService.updateTenant(tenantId, data);
|
|
||||||
setCurrentTenant(updatedTenant);
|
|
||||||
setTenants(prev => prev.map(t => t.id === tenantId ? updatedTenant : t));
|
|
||||||
|
|
||||||
return updatedTenant;
|
|
||||||
} catch (error) {
|
|
||||||
const message = error instanceof Error ? error.message : 'Failed to update tenant';
|
|
||||||
setError(message);
|
|
||||||
throw error;
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const getUserTenants = useCallback(async (): Promise<TenantInfo[]> => {
|
|
||||||
try {
|
|
||||||
setIsLoading(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
const userTenants = await tenantService.getUserTenants();
|
|
||||||
setTenants(userTenants);
|
|
||||||
|
|
||||||
return userTenants;
|
|
||||||
} catch (error) {
|
|
||||||
const message = error instanceof Error ? error.message : 'Failed to get user tenants';
|
|
||||||
setError(message);
|
|
||||||
throw error;
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const getTenantMembers = useCallback(async (tenantId: string): Promise<TenantMember[]> => {
|
|
||||||
try {
|
|
||||||
setIsLoading(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
const response = await tenantService.getTenantMembers(tenantId);
|
|
||||||
setMembers(response.data);
|
|
||||||
|
|
||||||
return response.data;
|
|
||||||
} catch (error) {
|
|
||||||
const message = error instanceof Error ? error.message : 'Failed to get tenant members';
|
|
||||||
setError(message);
|
|
||||||
throw error;
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const inviteUser = useCallback(async (tenantId: string, invitation: InviteUser): Promise<void> => {
|
|
||||||
try {
|
|
||||||
setIsLoading(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
await tenantService.inviteUser(tenantId, invitation);
|
|
||||||
|
|
||||||
// Refresh members list
|
|
||||||
await getTenantMembers(tenantId);
|
|
||||||
} catch (error) {
|
|
||||||
const message = error instanceof Error ? error.message : 'Failed to invite user';
|
|
||||||
setError(message);
|
|
||||||
throw error;
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
}, [getTenantMembers]);
|
|
||||||
|
|
||||||
const removeMember = useCallback(async (tenantId: string, userId: string): Promise<void> => {
|
|
||||||
try {
|
|
||||||
setIsLoading(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
await tenantService.removeMember(tenantId, userId);
|
|
||||||
setMembers(prev => prev.filter(m => m.user_id !== userId));
|
|
||||||
} catch (error) {
|
|
||||||
const message = error instanceof Error ? error.message : 'Failed to remove member';
|
|
||||||
setError(message);
|
|
||||||
throw error;
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const updateMemberRole = useCallback(async (tenantId: string, userId: string, role: string): Promise<void> => {
|
|
||||||
try {
|
|
||||||
setIsLoading(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
const updatedMember = await tenantService.updateMemberRole(tenantId, userId, role);
|
|
||||||
setMembers(prev => prev.map(m => m.user_id === userId ? updatedMember : m));
|
|
||||||
} catch (error) {
|
|
||||||
const message = error instanceof Error ? error.message : 'Failed to update member role';
|
|
||||||
setError(message);
|
|
||||||
throw error;
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const getTenantStats = useCallback(async (tenantId: string): Promise<TenantStats> => {
|
|
||||||
try {
|
|
||||||
setIsLoading(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
const tenantStats = await tenantService.getTenantStats(tenantId);
|
|
||||||
setStats(tenantStats);
|
|
||||||
|
|
||||||
return tenantStats;
|
|
||||||
} catch (error) {
|
|
||||||
const message = error instanceof Error ? error.message : 'Failed to get tenant stats';
|
|
||||||
setError(message);
|
|
||||||
throw error;
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return {
|
|
||||||
tenants,
|
|
||||||
currentTenant,
|
|
||||||
members,
|
|
||||||
stats,
|
|
||||||
isLoading,
|
|
||||||
error,
|
|
||||||
createTenant,
|
|
||||||
getTenant,
|
|
||||||
updateTenant,
|
|
||||||
getUserTenants,
|
|
||||||
getTenantMembers,
|
|
||||||
inviteUser,
|
|
||||||
removeMember,
|
|
||||||
updateMemberRole,
|
|
||||||
getTenantStats,
|
|
||||||
clearError: () => setError(null),
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
// Hook to get current tenant ID from context or state
|
|
||||||
export const useTenantId = () => {
|
|
||||||
const { currentTenant } = useTenant();
|
|
||||||
return currentTenant?.id || null;
|
|
||||||
};
|
|
||||||
@@ -1,265 +0,0 @@
|
|||||||
// frontend/src/api/hooks/useTraining.ts
|
|
||||||
/**
|
|
||||||
* Training Operations Hooks
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { useState, useCallback, useEffect } from 'react';
|
|
||||||
import { trainingService } from '../services';
|
|
||||||
import type {
|
|
||||||
TrainingJobRequest,
|
|
||||||
TrainingJobResponse,
|
|
||||||
ModelInfo,
|
|
||||||
ModelTrainingStats,
|
|
||||||
SingleProductTrainingRequest,
|
|
||||||
} from '../types';
|
|
||||||
|
|
||||||
interface UseTrainingOptions {
|
|
||||||
disablePolling?: boolean; // New option to disable HTTP status polling
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useTraining = (options: UseTrainingOptions = {}) => {
|
|
||||||
|
|
||||||
const { disablePolling = false } = options;
|
|
||||||
|
|
||||||
// Debug logging for option changes
|
|
||||||
console.log('🔧 useTraining initialized with options:', { disablePolling, options });
|
|
||||||
const [jobs, setJobs] = useState<TrainingJobResponse[]>([]);
|
|
||||||
const [currentJob, setCurrentJob] = useState<TrainingJobResponse | null>(null);
|
|
||||||
const [models, setModels] = useState<ModelInfo[]>([]);
|
|
||||||
const [stats, setStats] = useState<ModelTrainingStats | null>(null);
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const startTrainingJob = useCallback(async (
|
|
||||||
tenantId: string,
|
|
||||||
request: TrainingJobRequest
|
|
||||||
): Promise<TrainingJobResponse> => {
|
|
||||||
try {
|
|
||||||
setIsLoading(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
const job = await trainingService.startTrainingJob(tenantId, request);
|
|
||||||
setCurrentJob(job);
|
|
||||||
setJobs(prev => [job, ...prev]);
|
|
||||||
|
|
||||||
return job;
|
|
||||||
} catch (error) {
|
|
||||||
const message = error instanceof Error ? error.message : 'Failed to start training job';
|
|
||||||
setError(message);
|
|
||||||
throw error;
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const startSingleProductTraining = useCallback(async (
|
|
||||||
tenantId: string,
|
|
||||||
request: SingleProductTrainingRequest
|
|
||||||
): Promise<TrainingJobResponse> => {
|
|
||||||
try {
|
|
||||||
setIsLoading(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
const job = await trainingService.startSingleProductTraining(tenantId, request);
|
|
||||||
setCurrentJob(job);
|
|
||||||
setJobs(prev => [job, ...prev]);
|
|
||||||
|
|
||||||
return job;
|
|
||||||
} catch (error) {
|
|
||||||
const message = error instanceof Error ? error.message : 'Failed to start product training';
|
|
||||||
setError(message);
|
|
||||||
throw error;
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const getTrainingJobStatus = useCallback(async (
|
|
||||||
tenantId: string,
|
|
||||||
jobId: string
|
|
||||||
): Promise<TrainingJobResponse> => {
|
|
||||||
try {
|
|
||||||
const job = await trainingService.getTrainingJobStatus(tenantId, jobId);
|
|
||||||
|
|
||||||
// Update job in state
|
|
||||||
setJobs(prev => prev.map(j => j.job_id === jobId ? job : j));
|
|
||||||
if (currentJob?.job_id === jobId) {
|
|
||||||
setCurrentJob(job);
|
|
||||||
}
|
|
||||||
|
|
||||||
return job;
|
|
||||||
} catch (error) {
|
|
||||||
const message = error instanceof Error ? error.message : 'Failed to get job status';
|
|
||||||
setError(message);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}, [currentJob]);
|
|
||||||
|
|
||||||
const cancelTrainingJob = useCallback(async (
|
|
||||||
tenantId: string,
|
|
||||||
jobId: string
|
|
||||||
): Promise<void> => {
|
|
||||||
try {
|
|
||||||
setIsLoading(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
await trainingService.cancelTrainingJob(tenantId, jobId);
|
|
||||||
|
|
||||||
// Update job status in state
|
|
||||||
setJobs(prev => prev.map(j =>
|
|
||||||
j.job_id === jobId ? { ...j, status: 'cancelled' } : j
|
|
||||||
));
|
|
||||||
if (currentJob?.job_id === jobId) {
|
|
||||||
setCurrentJob({ ...currentJob, status: 'cancelled' });
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
const message = error instanceof Error ? error.message : 'Failed to cancel job';
|
|
||||||
setError(message);
|
|
||||||
throw error;
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
}, [currentJob]);
|
|
||||||
|
|
||||||
const getTrainingJobs = useCallback(async (tenantId: string): Promise<TrainingJobResponse[]> => {
|
|
||||||
try {
|
|
||||||
setIsLoading(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
const response = await trainingService.getTrainingJobs(tenantId);
|
|
||||||
setJobs(response.data);
|
|
||||||
|
|
||||||
return response.data;
|
|
||||||
} catch (error) {
|
|
||||||
const message = error instanceof Error ? error.message : 'Failed to get training jobs';
|
|
||||||
setError(message);
|
|
||||||
throw error;
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const getModels = useCallback(async (tenantId: string): Promise<ModelInfo[]> => {
|
|
||||||
try {
|
|
||||||
setIsLoading(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
const response = await trainingService.getModels(tenantId);
|
|
||||||
setModels(response.data);
|
|
||||||
|
|
||||||
return response.data;
|
|
||||||
} catch (error) {
|
|
||||||
const message = error instanceof Error ? error.message : 'Failed to get models';
|
|
||||||
setError(message);
|
|
||||||
throw error;
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const validateTrainingData = useCallback(async (tenantId: string): Promise<{
|
|
||||||
is_valid: boolean;
|
|
||||||
message: string;
|
|
||||||
details?: any;
|
|
||||||
}> => {
|
|
||||||
try {
|
|
||||||
setIsLoading(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
const result = await trainingService.validateTrainingData(tenantId);
|
|
||||||
return result;
|
|
||||||
} catch (error) {
|
|
||||||
const message = error instanceof Error ? error.message : 'Data validation failed';
|
|
||||||
setError(message);
|
|
||||||
throw error;
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const getTrainingStats = useCallback(async (tenantId: string): Promise<ModelTrainingStats> => {
|
|
||||||
try {
|
|
||||||
setIsLoading(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
const trainingStats = await trainingService.getTrainingStats(tenantId);
|
|
||||||
setStats(trainingStats);
|
|
||||||
|
|
||||||
return trainingStats;
|
|
||||||
} catch (error) {
|
|
||||||
const message = error instanceof Error ? error.message : 'Failed to get training stats';
|
|
||||||
setError(message);
|
|
||||||
throw error;
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// Always check disablePolling first and log for debugging
|
|
||||||
console.log('🔍 useTraining polling check:', {
|
|
||||||
disablePolling,
|
|
||||||
jobsCount: jobs.length,
|
|
||||||
runningJobs: jobs.filter(job => job.status === 'running' || job.status === 'pending').length
|
|
||||||
});
|
|
||||||
|
|
||||||
// STRICT CHECK: Skip polling if disabled - NO EXCEPTIONS
|
|
||||||
if (disablePolling === true) {
|
|
||||||
console.log('🚫 HTTP status polling STRICTLY DISABLED - using WebSocket instead');
|
|
||||||
console.log('🚫 Effect triggered but polling prevented by disablePolling flag');
|
|
||||||
return; // Early return - no cleanup needed, no interval creation
|
|
||||||
}
|
|
||||||
|
|
||||||
const runningJobs = jobs.filter(job => job.status === 'running' || job.status === 'pending');
|
|
||||||
|
|
||||||
if (runningJobs.length === 0) {
|
|
||||||
console.log('⏸️ No running jobs - skipping polling setup');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('🔄 Starting HTTP status polling for', runningJobs.length, 'jobs');
|
|
||||||
|
|
||||||
const interval = setInterval(async () => {
|
|
||||||
// Double-check disablePolling inside interval to prevent race conditions
|
|
||||||
if (disablePolling) {
|
|
||||||
console.log('🚫 Polling disabled during interval - clearing');
|
|
||||||
clearInterval(interval);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const job of runningJobs) {
|
|
||||||
try {
|
|
||||||
const tenantId = job.tenant_id;
|
|
||||||
console.log('📡 HTTP polling job status:', job.job_id);
|
|
||||||
await getTrainingJobStatus(tenantId, job.job_id);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to refresh job status:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, 5000); // Refresh every 5 seconds
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
console.log('🛑 Stopping HTTP status polling (cleanup)');
|
|
||||||
clearInterval(interval);
|
|
||||||
};
|
|
||||||
}, [jobs, getTrainingJobStatus, disablePolling]);
|
|
||||||
|
|
||||||
|
|
||||||
return {
|
|
||||||
jobs,
|
|
||||||
currentJob,
|
|
||||||
models,
|
|
||||||
stats,
|
|
||||||
isLoading,
|
|
||||||
error,
|
|
||||||
startTrainingJob,
|
|
||||||
startSingleProductTraining,
|
|
||||||
getTrainingJobStatus,
|
|
||||||
cancelTrainingJob,
|
|
||||||
getTrainingJobs,
|
|
||||||
getModels,
|
|
||||||
validateTrainingData,
|
|
||||||
getTrainingStats,
|
|
||||||
clearError: () => setError(null),
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
// frontend/src/api/index.ts
|
|
||||||
/**
|
|
||||||
* Main API Export
|
|
||||||
* Central entry point for all API functionality
|
|
||||||
*/
|
|
||||||
|
|
||||||
// Export main API client first
|
|
||||||
export { apiClient } from './client';
|
|
||||||
|
|
||||||
// Export all services
|
|
||||||
export {
|
|
||||||
authService,
|
|
||||||
tenantService,
|
|
||||||
salesService,
|
|
||||||
externalService,
|
|
||||||
trainingService,
|
|
||||||
forecastingService,
|
|
||||||
notificationService,
|
|
||||||
inventoryService,
|
|
||||||
api
|
|
||||||
} from './services';
|
|
||||||
|
|
||||||
// Export all hooks
|
|
||||||
export {
|
|
||||||
useAuth,
|
|
||||||
useAuthHeaders,
|
|
||||||
useTenant,
|
|
||||||
useSales,
|
|
||||||
useExternal,
|
|
||||||
useTraining,
|
|
||||||
useForecast,
|
|
||||||
useNotification,
|
|
||||||
useApiHooks,
|
|
||||||
useOnboarding,
|
|
||||||
useInventory,
|
|
||||||
useInventoryProducts
|
|
||||||
} from './hooks';
|
|
||||||
|
|
||||||
// Export WebSocket functionality
|
|
||||||
export {
|
|
||||||
WebSocketManager,
|
|
||||||
useWebSocket,
|
|
||||||
useTrainingWebSocket,
|
|
||||||
useForecastWebSocket,
|
|
||||||
} from './websocket';
|
|
||||||
|
|
||||||
// Export WebSocket types
|
|
||||||
export type {
|
|
||||||
WebSocketConfig,
|
|
||||||
WebSocketMessage,
|
|
||||||
WebSocketHandlers,
|
|
||||||
WebSocketStatus,
|
|
||||||
WebSocketMetrics,
|
|
||||||
} from './websocket';
|
|
||||||
|
|
||||||
// Export types
|
|
||||||
export * from './types';
|
|
||||||
|
|
||||||
// Export configuration
|
|
||||||
export { apiConfig, serviceEndpoints, featureFlags } from './client/config';
|
|
||||||
|
|
||||||
// Setup interceptors on import (move to end to avoid circular deps)
|
|
||||||
import { setupInterceptors } from './client/interceptors';
|
|
||||||
setupInterceptors();
|
|
||||||
@@ -1,107 +0,0 @@
|
|||||||
// frontend/src/api/services/auth.service.ts
|
|
||||||
/**
|
|
||||||
* Authentication Service
|
|
||||||
* Handles all authentication-related API calls
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { apiClient } from '../client';
|
|
||||||
import { serviceEndpoints } from '../client/config';
|
|
||||||
import type {
|
|
||||||
LoginRequest,
|
|
||||||
LoginResponse,
|
|
||||||
RegisterRequest,
|
|
||||||
UserResponse,
|
|
||||||
PasswordResetRequest,
|
|
||||||
PasswordResetResponse,
|
|
||||||
PasswordResetConfirmRequest,
|
|
||||||
TokenVerification,
|
|
||||||
LogoutResponse,
|
|
||||||
} from '../types';
|
|
||||||
|
|
||||||
export class AuthService {
|
|
||||||
private baseEndpoint = serviceEndpoints.auth;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* User Registration
|
|
||||||
*/
|
|
||||||
async register(data: RegisterRequest): Promise<LoginResponse> {
|
|
||||||
return apiClient.post(`${this.baseEndpoint}/register`, data);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* User Login
|
|
||||||
*/
|
|
||||||
async login(credentials: LoginRequest): Promise<LoginResponse> {
|
|
||||||
return apiClient.post(`${this.baseEndpoint}/login`, credentials);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* User Logout
|
|
||||||
*/
|
|
||||||
async logout(): Promise<LogoutResponse> {
|
|
||||||
return apiClient.post(`${this.baseEndpoint}/logout`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get Current User Profile
|
|
||||||
*/
|
|
||||||
async getCurrentUser(): Promise<UserResponse> {
|
|
||||||
return apiClient.get(`/users/me`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update User Profile
|
|
||||||
*/
|
|
||||||
async updateProfile(data: Partial<UserResponse>): Promise<UserResponse> {
|
|
||||||
return apiClient.put(`/users/me`, data);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Verify Token
|
|
||||||
*/
|
|
||||||
async verifyToken(token: string): Promise<TokenVerification> {
|
|
||||||
return apiClient.post(`${this.baseEndpoint}/verify-token`, { token });
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Refresh Access Token
|
|
||||||
*/
|
|
||||||
async refreshToken(refreshToken: string): Promise<LoginResponse> {
|
|
||||||
return apiClient.post(`${this.baseEndpoint}/refresh`, {
|
|
||||||
refresh_token: refreshToken,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Request Password Reset
|
|
||||||
*/
|
|
||||||
async requestPasswordReset(data: PasswordResetRequest): Promise<PasswordResetResponse> {
|
|
||||||
return apiClient.post(`${this.baseEndpoint}/password-reset`, data);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Confirm Password Reset
|
|
||||||
*/
|
|
||||||
async confirmPasswordReset(data: PasswordResetConfirmRequest): Promise<{ message: string }> {
|
|
||||||
return apiClient.post(`${this.baseEndpoint}/password-reset/confirm`, data);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Change Password (for authenticated users)
|
|
||||||
*/
|
|
||||||
async changePassword(currentPassword: string, newPassword: string): Promise<{ message: string }> {
|
|
||||||
return apiClient.post(`/users/me/change-password`, {
|
|
||||||
current_password: currentPassword,
|
|
||||||
new_password: newPassword,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete User Account
|
|
||||||
*/
|
|
||||||
async deleteAccount(): Promise<{ message: string }> {
|
|
||||||
return apiClient.delete(`/users/me`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const authService = new AuthService();
|
|
||||||
@@ -1,296 +0,0 @@
|
|||||||
// frontend/src/api/services/external.service.ts
|
|
||||||
/**
|
|
||||||
* External Data Service
|
|
||||||
* Handles weather and traffic data operations for the external microservice
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { apiClient } from '../client';
|
|
||||||
import { RequestTimeouts } from '../client/config';
|
|
||||||
|
|
||||||
// Align with backend WeatherDataResponse schema
|
|
||||||
export interface WeatherData {
|
|
||||||
date: string;
|
|
||||||
temperature?: number;
|
|
||||||
precipitation?: number;
|
|
||||||
humidity?: number;
|
|
||||||
wind_speed?: number;
|
|
||||||
pressure?: number;
|
|
||||||
description?: string;
|
|
||||||
source: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Align with backend TrafficDataResponse schema
|
|
||||||
export interface TrafficData {
|
|
||||||
date: string;
|
|
||||||
traffic_volume?: number;
|
|
||||||
pedestrian_count?: number;
|
|
||||||
congestion_level?: string;
|
|
||||||
average_speed?: number;
|
|
||||||
source: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface WeatherForecast {
|
|
||||||
date: string;
|
|
||||||
temperature_min: number;
|
|
||||||
temperature_max: number;
|
|
||||||
temperature_avg: number;
|
|
||||||
precipitation: number;
|
|
||||||
description: string;
|
|
||||||
humidity?: number;
|
|
||||||
wind_speed?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface HourlyForecast {
|
|
||||||
forecast_datetime: string;
|
|
||||||
generated_at: string;
|
|
||||||
temperature: number;
|
|
||||||
precipitation: number;
|
|
||||||
humidity: number;
|
|
||||||
wind_speed: number;
|
|
||||||
description: string;
|
|
||||||
source: string;
|
|
||||||
hour: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class ExternalService {
|
|
||||||
/**
|
|
||||||
* Get Current Weather Data
|
|
||||||
*/
|
|
||||||
async getCurrentWeather(
|
|
||||||
tenantId: string,
|
|
||||||
lat: number,
|
|
||||||
lon: number
|
|
||||||
): Promise<WeatherData> {
|
|
||||||
try {
|
|
||||||
// ✅ FIX 1: Correct endpoint path with tenant ID
|
|
||||||
const endpoint = `/tenants/${tenantId}/weather/current`;
|
|
||||||
|
|
||||||
// ✅ FIX 2: Correct parameter names (latitude/longitude, not lat/lon)
|
|
||||||
const response = await apiClient.get(endpoint, {
|
|
||||||
params: {
|
|
||||||
latitude: lat, // Backend expects 'latitude'
|
|
||||||
longitude: lon // Backend expects 'longitude'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('Weather API response:', response);
|
|
||||||
|
|
||||||
// Return backend response directly (matches WeatherData interface)
|
|
||||||
return response;
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to fetch weather from AEMET API via backend:', error);
|
|
||||||
throw new Error(`Weather data unavailable: ${error instanceof Error ? error.message : 'AEMET API connection failed'}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get Weather Forecast
|
|
||||||
*/
|
|
||||||
async getWeatherForecast(
|
|
||||||
tenantId: string,
|
|
||||||
lat: number,
|
|
||||||
lon: number,
|
|
||||||
days: number = 7
|
|
||||||
): Promise<WeatherForecast[]> {
|
|
||||||
try {
|
|
||||||
// Fix: Use POST with JSON body as expected by backend
|
|
||||||
const response = await apiClient.post(`/tenants/${tenantId}/weather/forecast`, {
|
|
||||||
latitude: lat,
|
|
||||||
longitude: lon,
|
|
||||||
days: days
|
|
||||||
});
|
|
||||||
|
|
||||||
// Handle response format
|
|
||||||
if (Array.isArray(response)) {
|
|
||||||
return response;
|
|
||||||
} else if (response && response.forecasts) {
|
|
||||||
return response.forecasts;
|
|
||||||
} else {
|
|
||||||
console.warn('Unexpected weather forecast response format:', response);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to fetch weather forecast from AEMET API:', error);
|
|
||||||
throw new Error(`Weather forecast unavailable: ${error instanceof Error ? error.message : 'AEMET API connection failed'}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get Hourly Weather Forecast (NEW)
|
|
||||||
*/
|
|
||||||
async getHourlyWeatherForecast(
|
|
||||||
tenantId: string,
|
|
||||||
lat: number,
|
|
||||||
lon: number,
|
|
||||||
hours: number = 48
|
|
||||||
): Promise<HourlyForecast[]> {
|
|
||||||
try {
|
|
||||||
console.log(`🕒 Fetching hourly weather forecast from AEMET API for tenant ${tenantId}`, {
|
|
||||||
latitude: lat,
|
|
||||||
longitude: lon,
|
|
||||||
hours: hours
|
|
||||||
});
|
|
||||||
|
|
||||||
const response = await apiClient.post(`/tenants/${tenantId}/weather/hourly-forecast`, {
|
|
||||||
latitude: lat,
|
|
||||||
longitude: lon,
|
|
||||||
hours: hours
|
|
||||||
});
|
|
||||||
|
|
||||||
// Handle response format
|
|
||||||
if (Array.isArray(response)) {
|
|
||||||
return response;
|
|
||||||
} else if (response && response.data) {
|
|
||||||
return response.data;
|
|
||||||
} else {
|
|
||||||
console.warn('Unexpected hourly forecast response format:', response);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to fetch hourly forecast from AEMET API:', error);
|
|
||||||
throw new Error(`Hourly forecast unavailable: ${error instanceof Error ? error.message : 'AEMET API connection failed'}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get Historical Weather Data
|
|
||||||
*/
|
|
||||||
async getHistoricalWeather(
|
|
||||||
tenantId: string,
|
|
||||||
lat: number,
|
|
||||||
lon: number,
|
|
||||||
startDate: string,
|
|
||||||
endDate: string
|
|
||||||
): Promise<WeatherData[]> {
|
|
||||||
try {
|
|
||||||
// Fix: Use POST with JSON body as expected by backend
|
|
||||||
const response = await apiClient.post(`/tenants/${tenantId}/weather/historical`, {
|
|
||||||
latitude: lat,
|
|
||||||
longitude: lon,
|
|
||||||
start_date: startDate,
|
|
||||||
end_date: endDate
|
|
||||||
});
|
|
||||||
|
|
||||||
// Return backend response directly (matches WeatherData interface)
|
|
||||||
return Array.isArray(response) ? response : response.data || [];
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to fetch historical weather from AEMET API:', error);
|
|
||||||
throw new Error(`Historical weather data unavailable: ${error instanceof Error ? error.message : 'AEMET API connection failed'}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get Current Traffic Data
|
|
||||||
*/
|
|
||||||
async getCurrentTraffic(
|
|
||||||
tenantId: string,
|
|
||||||
lat: number,
|
|
||||||
lon: number
|
|
||||||
): Promise<TrafficData> {
|
|
||||||
try {
|
|
||||||
const response = await apiClient.get(`/tenants/${tenantId}/traffic/current`, {
|
|
||||||
params: {
|
|
||||||
latitude: lat,
|
|
||||||
longitude: lon
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Return backend response directly (matches TrafficData interface)
|
|
||||||
return response;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to fetch traffic data from external API:', error);
|
|
||||||
throw new Error(`Traffic data unavailable: ${error instanceof Error ? error.message : 'External API connection failed'}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get Traffic Forecast
|
|
||||||
*/
|
|
||||||
async getTrafficForecast(
|
|
||||||
tenantId: string,
|
|
||||||
lat: number,
|
|
||||||
lon: number,
|
|
||||||
hours: number = 24
|
|
||||||
): Promise<TrafficData[]> {
|
|
||||||
try {
|
|
||||||
// Fix: Use POST with JSON body as expected by backend
|
|
||||||
const response = await apiClient.post(`/tenants/${tenantId}/traffic/forecast`, {
|
|
||||||
latitude: lat,
|
|
||||||
longitude: lon,
|
|
||||||
hours: hours
|
|
||||||
});
|
|
||||||
|
|
||||||
// Return backend response directly (matches TrafficData interface)
|
|
||||||
return Array.isArray(response) ? response : response.data || [];
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to fetch traffic forecast from external API:', error);
|
|
||||||
throw new Error(`Traffic forecast unavailable: ${error instanceof Error ? error.message : 'External API connection failed'}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get Historical Traffic Data
|
|
||||||
*/
|
|
||||||
async getHistoricalTraffic(
|
|
||||||
tenantId: string,
|
|
||||||
lat: number,
|
|
||||||
lon: number,
|
|
||||||
startDate: string,
|
|
||||||
endDate: string
|
|
||||||
): Promise<TrafficData[]> {
|
|
||||||
try {
|
|
||||||
// Fix: Use POST with JSON body as expected by backend
|
|
||||||
const response = await apiClient.post(`/tenants/${tenantId}/traffic/historical`, {
|
|
||||||
latitude: lat,
|
|
||||||
longitude: lon,
|
|
||||||
start_date: startDate,
|
|
||||||
end_date: endDate
|
|
||||||
});
|
|
||||||
|
|
||||||
// Return backend response directly (matches TrafficData interface)
|
|
||||||
return Array.isArray(response) ? response : response.data || [];
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to fetch historical traffic from external API:', error);
|
|
||||||
throw new Error(`Historical traffic data unavailable: ${error instanceof Error ? error.message : 'External API connection failed'}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Test External Service Connectivity
|
|
||||||
*/
|
|
||||||
async testConnectivity(tenantId: string): Promise<{
|
|
||||||
weather: boolean;
|
|
||||||
traffic: boolean;
|
|
||||||
overall: boolean;
|
|
||||||
}> {
|
|
||||||
const results = {
|
|
||||||
weather: false,
|
|
||||||
traffic: false,
|
|
||||||
overall: false
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Test weather service (AEMET API)
|
|
||||||
await this.getCurrentWeather(tenantId, 40.4168, -3.7038); // Madrid coordinates
|
|
||||||
results.weather = true;
|
|
||||||
} catch (error) {
|
|
||||||
console.warn('AEMET weather service connectivity test failed:', error);
|
|
||||||
results.weather = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Test traffic service
|
|
||||||
await this.getCurrentTraffic(tenantId, 40.4168, -3.7038); // Madrid coordinates
|
|
||||||
results.traffic = true;
|
|
||||||
} catch (error) {
|
|
||||||
console.warn('Traffic service connectivity test failed:', error);
|
|
||||||
results.traffic = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
results.overall = results.weather && results.traffic;
|
|
||||||
return results;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const externalService = new ExternalService();
|
|
||||||
@@ -1,301 +0,0 @@
|
|||||||
// frontend/src/api/services/forecasting.service.ts
|
|
||||||
/**
|
|
||||||
* Forecasting Service
|
|
||||||
* Handles forecast operations and predictions
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { apiClient } from '../client';
|
|
||||||
import { RequestTimeouts } from '../client/config';
|
|
||||||
import type {
|
|
||||||
SingleForecastRequest,
|
|
||||||
BatchForecastRequest,
|
|
||||||
ForecastResponse,
|
|
||||||
BatchForecastResponse,
|
|
||||||
ForecastAlert,
|
|
||||||
QuickForecast,
|
|
||||||
PaginatedResponse,
|
|
||||||
BaseQueryParams,
|
|
||||||
} from '../types';
|
|
||||||
|
|
||||||
export class ForecastingService {
|
|
||||||
/**
|
|
||||||
* Create Single Product Forecast
|
|
||||||
*/
|
|
||||||
async createSingleForecast(
|
|
||||||
tenantId: string,
|
|
||||||
request: SingleForecastRequest
|
|
||||||
): Promise<ForecastResponse[]> {
|
|
||||||
console.log('🔮 Creating single forecast:', { tenantId, request });
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Backend returns single ForecastResponse object
|
|
||||||
const response = await apiClient.post(
|
|
||||||
`/tenants/${tenantId}/forecasts/single`,
|
|
||||||
request,
|
|
||||||
{
|
|
||||||
timeout: RequestTimeouts.MEDIUM,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
console.log('🔮 Forecast API Response:', response);
|
|
||||||
console.log('- Type:', typeof response);
|
|
||||||
console.log('- Is Array:', Array.isArray(response));
|
|
||||||
|
|
||||||
// ✅ FIX: Convert single response to array
|
|
||||||
if (response && typeof response === 'object' && !Array.isArray(response)) {
|
|
||||||
// Single forecast response - wrap in array
|
|
||||||
const forecastArray = [response as ForecastResponse];
|
|
||||||
console.log('✅ Converted single forecast to array:', forecastArray);
|
|
||||||
return forecastArray;
|
|
||||||
} else if (Array.isArray(response)) {
|
|
||||||
// Already an array (unexpected but handle gracefully)
|
|
||||||
console.log('✅ Response is already an array:', response);
|
|
||||||
return response;
|
|
||||||
} else {
|
|
||||||
console.error('❌ Unexpected response format:', response);
|
|
||||||
throw new Error('Invalid forecast response format');
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('❌ Forecast API Error:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create Batch Forecast
|
|
||||||
*/
|
|
||||||
async createBatchForecast(
|
|
||||||
tenantId: string,
|
|
||||||
request: BatchForecastRequest
|
|
||||||
): Promise<BatchForecastResponse> {
|
|
||||||
return apiClient.post(
|
|
||||||
`/tenants/${tenantId}/forecasts/batch`,
|
|
||||||
request,
|
|
||||||
{
|
|
||||||
timeout: RequestTimeouts.LONG,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get Forecast by ID
|
|
||||||
*/
|
|
||||||
async getForecast(tenantId: string, forecastId: string): Promise<ForecastResponse> {
|
|
||||||
return apiClient.get(`/tenants/${tenantId}/forecasts/${forecastId}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get Forecasts
|
|
||||||
*/
|
|
||||||
async getForecasts(
|
|
||||||
tenantId: string,
|
|
||||||
params?: BaseQueryParams & {
|
|
||||||
inventory_product_id?: string; // Primary way to filter by product
|
|
||||||
product_name?: string; // For backward compatibility - will need inventory service lookup
|
|
||||||
start_date?: string;
|
|
||||||
end_date?: string;
|
|
||||||
model_id?: string;
|
|
||||||
}
|
|
||||||
): Promise<PaginatedResponse<ForecastResponse>> {
|
|
||||||
return apiClient.get(`/tenants/${tenantId}/forecasts`, { params });
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get Batch Forecast Status
|
|
||||||
*/
|
|
||||||
async getBatchForecastStatus(
|
|
||||||
tenantId: string,
|
|
||||||
batchId: string
|
|
||||||
): Promise<BatchForecastResponse> {
|
|
||||||
return apiClient.get(`/tenants/${tenantId}/forecasts/batch/${batchId}/status`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get Batch Forecasts
|
|
||||||
*/
|
|
||||||
async getBatchForecasts(
|
|
||||||
tenantId: string,
|
|
||||||
params?: BaseQueryParams & {
|
|
||||||
status?: string;
|
|
||||||
start_date?: string;
|
|
||||||
end_date?: string;
|
|
||||||
}
|
|
||||||
): Promise<PaginatedResponse<BatchForecastResponse>> {
|
|
||||||
return apiClient.get(`/tenants/${tenantId}/forecasts/batch`, { params });
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Cancel Batch Forecast
|
|
||||||
*/
|
|
||||||
async cancelBatchForecast(tenantId: string, batchId: string): Promise<{ message: string }> {
|
|
||||||
return apiClient.post(`/tenants/${tenantId}/forecasts/batch/${batchId}/cancel`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get Quick Forecasts for Dashboard
|
|
||||||
*/
|
|
||||||
async getQuickForecasts(tenantId: string, limit?: number): Promise<QuickForecast[]> {
|
|
||||||
try {
|
|
||||||
// TODO: Replace with actual /forecasts/quick endpoint when available
|
|
||||||
// For now, use regular forecasts endpoint and transform the data
|
|
||||||
const forecasts = await apiClient.get(`/tenants/${tenantId}/forecasts`, {
|
|
||||||
params: { limit: limit || 10 },
|
|
||||||
});
|
|
||||||
|
|
||||||
// Transform regular forecasts to QuickForecast format
|
|
||||||
// Handle response structure: { tenant_id, forecasts: [...], total_returned }
|
|
||||||
let forecastsArray: any[] = [];
|
|
||||||
|
|
||||||
if (Array.isArray(forecasts)) {
|
|
||||||
// Direct array response (unexpected)
|
|
||||||
forecastsArray = forecasts;
|
|
||||||
} else if (forecasts && typeof forecasts === 'object' && Array.isArray(forecasts.forecasts)) {
|
|
||||||
// Expected object response with forecasts array
|
|
||||||
forecastsArray = forecasts.forecasts;
|
|
||||||
} else {
|
|
||||||
console.warn('Unexpected forecasts response format:', forecasts);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
return forecastsArray.map((forecast: any) => ({
|
|
||||||
inventory_product_id: forecast.inventory_product_id,
|
|
||||||
product_name: forecast.product_name, // Optional - for display
|
|
||||||
next_day_prediction: forecast.predicted_demand || 0,
|
|
||||||
next_week_avg: forecast.predicted_demand || 0,
|
|
||||||
trend_direction: 'stable' as const,
|
|
||||||
confidence_score: forecast.confidence_level || 0.8,
|
|
||||||
last_updated: forecast.created_at || new Date().toISOString()
|
|
||||||
}));
|
|
||||||
} catch (error) {
|
|
||||||
console.error('QuickForecasts API call failed, using fallback data:', error);
|
|
||||||
|
|
||||||
// Return mock data for common bakery products (using mock inventory_product_ids)
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
inventory_product_id: 'mock-pan-de-molde-001',
|
|
||||||
product_name: 'Pan de Molde',
|
|
||||||
next_day_prediction: 25,
|
|
||||||
next_week_avg: 175,
|
|
||||||
trend_direction: 'stable',
|
|
||||||
confidence_score: 0.85,
|
|
||||||
last_updated: new Date().toISOString()
|
|
||||||
},
|
|
||||||
{
|
|
||||||
inventory_product_id: 'mock-baguettes-002',
|
|
||||||
product_name: 'Baguettes',
|
|
||||||
next_day_prediction: 20,
|
|
||||||
next_week_avg: 140,
|
|
||||||
trend_direction: 'up',
|
|
||||||
confidence_score: 0.92,
|
|
||||||
last_updated: new Date().toISOString()
|
|
||||||
},
|
|
||||||
{
|
|
||||||
inventory_product_id: 'mock-croissants-003',
|
|
||||||
product_name: 'Croissants',
|
|
||||||
next_day_prediction: 15,
|
|
||||||
next_week_avg: 105,
|
|
||||||
trend_direction: 'stable',
|
|
||||||
confidence_score: 0.78,
|
|
||||||
last_updated: new Date().toISOString()
|
|
||||||
},
|
|
||||||
{
|
|
||||||
inventory_product_id: 'mock-magdalenas-004',
|
|
||||||
product_name: 'Magdalenas',
|
|
||||||
next_day_prediction: 12,
|
|
||||||
next_week_avg: 84,
|
|
||||||
trend_direction: 'down',
|
|
||||||
confidence_score: 0.76,
|
|
||||||
last_updated: new Date().toISOString()
|
|
||||||
}
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get Forecast Alerts
|
|
||||||
*/
|
|
||||||
async getForecastAlerts(
|
|
||||||
tenantId: string,
|
|
||||||
params?: BaseQueryParams & {
|
|
||||||
is_active?: boolean;
|
|
||||||
severity?: string;
|
|
||||||
alert_type?: string;
|
|
||||||
}
|
|
||||||
): Promise<PaginatedResponse<ForecastAlert>> {
|
|
||||||
return apiClient.get(`/tenants/${tenantId}/forecasts/alerts`, { params });
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Acknowledge Forecast Alert
|
|
||||||
*/
|
|
||||||
async acknowledgeForecastAlert(
|
|
||||||
tenantId: string,
|
|
||||||
alertId: string
|
|
||||||
): Promise<ForecastAlert> {
|
|
||||||
return apiClient.post(`/tenants/${tenantId}/forecasts/alerts/${alertId}/acknowledge`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete Forecast
|
|
||||||
*/
|
|
||||||
async deleteForecast(tenantId: string, forecastId: string): Promise<{ message: string }> {
|
|
||||||
return apiClient.delete(`/tenants/${tenantId}/forecasts/${forecastId}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Export Forecasts
|
|
||||||
*/
|
|
||||||
async exportForecasts(
|
|
||||||
tenantId: string,
|
|
||||||
format: 'csv' | 'excel' | 'json',
|
|
||||||
params?: {
|
|
||||||
inventory_product_id?: string; // Primary way to filter by product
|
|
||||||
product_name?: string; // For backward compatibility
|
|
||||||
start_date?: string;
|
|
||||||
end_date?: string;
|
|
||||||
}
|
|
||||||
): Promise<Blob> {
|
|
||||||
const response = await apiClient.request(`/tenants/${tenantId}/forecasts/export`, {
|
|
||||||
method: 'GET',
|
|
||||||
params: { ...params, format },
|
|
||||||
headers: {
|
|
||||||
'Accept': format === 'csv' ? 'text/csv' :
|
|
||||||
format === 'excel' ? 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' :
|
|
||||||
'application/json',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return new Blob([response], {
|
|
||||||
type: format === 'csv' ? 'text/csv' :
|
|
||||||
format === 'excel' ? 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' :
|
|
||||||
'application/json',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get Forecast Accuracy Metrics
|
|
||||||
*/
|
|
||||||
async getForecastAccuracy(
|
|
||||||
tenantId: string,
|
|
||||||
params?: {
|
|
||||||
inventory_product_id?: string; // Primary way to filter by product
|
|
||||||
product_name?: string; // For backward compatibility
|
|
||||||
model_id?: string;
|
|
||||||
start_date?: string;
|
|
||||||
end_date?: string;
|
|
||||||
}
|
|
||||||
): Promise<{
|
|
||||||
overall_accuracy: number;
|
|
||||||
product_accuracy: Array<{
|
|
||||||
inventory_product_id: string;
|
|
||||||
product_name?: string; // Optional - for display
|
|
||||||
accuracy: number;
|
|
||||||
sample_size: number;
|
|
||||||
}>;
|
|
||||||
}> {
|
|
||||||
return apiClient.get(`/tenants/${tenantId}/forecasts/accuracy`, { params });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const forecastingService = new ForecastingService();
|
|
||||||
@@ -1,145 +0,0 @@
|
|||||||
// frontend/src/api/services/index.ts
|
|
||||||
/**
|
|
||||||
* Main Services Export
|
|
||||||
* Central export point for all API services
|
|
||||||
*/
|
|
||||||
|
|
||||||
// Import and export individual services
|
|
||||||
import { AuthService } from './auth.service';
|
|
||||||
import { TenantService } from './tenant.service';
|
|
||||||
import { SalesService } from './sales.service';
|
|
||||||
import { ExternalService } from './external.service';
|
|
||||||
import { TrainingService } from './training.service';
|
|
||||||
import { ForecastingService } from './forecasting.service';
|
|
||||||
import { NotificationService } from './notification.service';
|
|
||||||
import { OnboardingService } from './onboarding.service';
|
|
||||||
import { InventoryService } from './inventory.service';
|
|
||||||
import { RecipesService } from './recipes.service';
|
|
||||||
import { ProductionService } from './production.service';
|
|
||||||
import { OrdersService } from './orders.service';
|
|
||||||
import { SuppliersService } from './suppliers.service';
|
|
||||||
import { ProcurementService } from './procurement.service';
|
|
||||||
|
|
||||||
// Create service instances
|
|
||||||
export const authService = new AuthService();
|
|
||||||
export const tenantService = new TenantService();
|
|
||||||
export const salesService = new SalesService();
|
|
||||||
export const externalService = new ExternalService();
|
|
||||||
export const trainingService = new TrainingService();
|
|
||||||
export const forecastingService = new ForecastingService();
|
|
||||||
export const notificationService = new NotificationService();
|
|
||||||
export const onboardingService = new OnboardingService();
|
|
||||||
export const inventoryService = new InventoryService();
|
|
||||||
export const recipesService = new RecipesService();
|
|
||||||
export const productionService = new ProductionService();
|
|
||||||
export const ordersService = new OrdersService();
|
|
||||||
export const suppliersService = new SuppliersService();
|
|
||||||
export const procurementService = new ProcurementService();
|
|
||||||
|
|
||||||
// Export the classes as well
|
|
||||||
export {
|
|
||||||
AuthService,
|
|
||||||
TenantService,
|
|
||||||
SalesService,
|
|
||||||
ExternalService,
|
|
||||||
TrainingService,
|
|
||||||
ForecastingService,
|
|
||||||
NotificationService,
|
|
||||||
OnboardingService,
|
|
||||||
InventoryService,
|
|
||||||
RecipesService,
|
|
||||||
ProductionService,
|
|
||||||
OrdersService,
|
|
||||||
SuppliersService,
|
|
||||||
ProcurementService
|
|
||||||
};
|
|
||||||
|
|
||||||
// Import base client
|
|
||||||
import { apiClient } from '../client';
|
|
||||||
export { apiClient };
|
|
||||||
|
|
||||||
// Re-export all types
|
|
||||||
export * from '../types';
|
|
||||||
|
|
||||||
// Create unified API object
|
|
||||||
export const api = {
|
|
||||||
auth: authService,
|
|
||||||
tenant: tenantService,
|
|
||||||
sales: salesService,
|
|
||||||
external: externalService,
|
|
||||||
training: trainingService,
|
|
||||||
forecasting: forecastingService,
|
|
||||||
notification: notificationService,
|
|
||||||
onboarding: onboardingService,
|
|
||||||
inventory: inventoryService,
|
|
||||||
recipes: recipesService,
|
|
||||||
production: productionService,
|
|
||||||
orders: ordersService,
|
|
||||||
suppliers: suppliersService,
|
|
||||||
procurement: procurementService,
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
// Service status checking
|
|
||||||
export interface ServiceHealth {
|
|
||||||
service: string;
|
|
||||||
status: 'healthy' | 'degraded' | 'down';
|
|
||||||
lastChecked: Date;
|
|
||||||
responseTime?: number;
|
|
||||||
error?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class HealthService {
|
|
||||||
async checkServiceHealth(): Promise<ServiceHealth[]> {
|
|
||||||
const services = [
|
|
||||||
{ name: 'Auth', endpoint: '/auth/health' },
|
|
||||||
{ name: 'Tenant', endpoint: '/tenants/health' },
|
|
||||||
{ name: 'Sales', endpoint: '/sales/health' },
|
|
||||||
{ name: 'External', endpoint: '/external/health' },
|
|
||||||
{ name: 'Training', endpoint: '/training/health' },
|
|
||||||
{ name: 'Inventory', endpoint: '/inventory/health' },
|
|
||||||
{ name: 'Production', endpoint: '/production/health' },
|
|
||||||
{ name: 'Orders', endpoint: '/orders/health' },
|
|
||||||
{ name: 'Suppliers', endpoint: '/suppliers/health' },
|
|
||||||
{ name: 'Forecasting', endpoint: '/forecasting/health' },
|
|
||||||
{ name: 'Notification', endpoint: '/notifications/health' },
|
|
||||||
{ name: 'Procurement', endpoint: '/procurement-plans/health' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const healthChecks = await Promise.allSettled(
|
|
||||||
services.map(async (service) => {
|
|
||||||
const startTime = Date.now();
|
|
||||||
try {
|
|
||||||
await apiClient.get(service.endpoint, { timeout: 5000 });
|
|
||||||
const responseTime = Date.now() - startTime;
|
|
||||||
|
|
||||||
return {
|
|
||||||
service: service.name,
|
|
||||||
status: 'healthy' as const,
|
|
||||||
lastChecked: new Date(),
|
|
||||||
responseTime,
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
return {
|
|
||||||
service: service.name,
|
|
||||||
status: 'down' as const,
|
|
||||||
lastChecked: new Date(),
|
|
||||||
error: error instanceof Error ? error.message : 'Unknown error',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
return healthChecks.map((result, index) =>
|
|
||||||
result.status === 'fulfilled'
|
|
||||||
? result.value
|
|
||||||
: {
|
|
||||||
service: services[index].name,
|
|
||||||
status: 'down' as const,
|
|
||||||
lastChecked: new Date(),
|
|
||||||
error: 'Health check failed',
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const healthService = new HealthService();
|
|
||||||
@@ -1,749 +0,0 @@
|
|||||||
// frontend/src/api/services/inventory.service.ts
|
|
||||||
/**
|
|
||||||
* Inventory Service
|
|
||||||
* Handles inventory management, stock tracking, and product operations
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { apiClient } from '../client';
|
|
||||||
import type { ProductInfo } from '../types';
|
|
||||||
|
|
||||||
// ========== TYPES AND INTERFACES ==========
|
|
||||||
|
|
||||||
export type ProductType = 'ingredient' | 'finished_product';
|
|
||||||
|
|
||||||
export type UnitOfMeasure =
|
|
||||||
| 'kilograms' | 'grams' | 'liters' | 'milliliters'
|
|
||||||
| 'units' | 'pieces' | 'dozens' | 'boxes';
|
|
||||||
|
|
||||||
export type IngredientCategory =
|
|
||||||
| 'flour' | 'yeast' | 'dairy' | 'eggs' | 'sugar'
|
|
||||||
| 'fats' | 'salt' | 'spices' | 'additives' | 'packaging';
|
|
||||||
|
|
||||||
export type ProductCategory =
|
|
||||||
| 'bread' | 'croissants' | 'pastries' | 'cakes'
|
|
||||||
| 'cookies' | 'muffins' | 'sandwiches' | 'beverages' | 'other_products';
|
|
||||||
|
|
||||||
export type StockMovementType =
|
|
||||||
| 'purchase' | 'consumption' | 'adjustment'
|
|
||||||
| 'waste' | 'transfer' | 'return';
|
|
||||||
|
|
||||||
export interface InventoryItem {
|
|
||||||
id: string;
|
|
||||||
tenant_id: string;
|
|
||||||
name: string;
|
|
||||||
product_type: ProductType;
|
|
||||||
category: IngredientCategory | ProductCategory;
|
|
||||||
unit_of_measure: UnitOfMeasure;
|
|
||||||
estimated_shelf_life_days?: number;
|
|
||||||
requires_refrigeration: boolean;
|
|
||||||
requires_freezing: boolean;
|
|
||||||
is_seasonal: boolean;
|
|
||||||
minimum_stock_level?: number;
|
|
||||||
maximum_stock_level?: number;
|
|
||||||
reorder_point?: number;
|
|
||||||
supplier?: string;
|
|
||||||
notes?: string;
|
|
||||||
barcode?: string;
|
|
||||||
sku?: string;
|
|
||||||
cost_per_unit?: number;
|
|
||||||
is_active: boolean;
|
|
||||||
created_at: string;
|
|
||||||
updated_at: string;
|
|
||||||
|
|
||||||
// Computed fields
|
|
||||||
current_stock?: StockLevel;
|
|
||||||
low_stock_alert?: boolean;
|
|
||||||
expiring_soon_alert?: boolean;
|
|
||||||
recent_movements?: StockMovement[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface StockLevel {
|
|
||||||
item_id: string;
|
|
||||||
current_quantity: number;
|
|
||||||
available_quantity: number;
|
|
||||||
reserved_quantity: number;
|
|
||||||
unit_of_measure: UnitOfMeasure;
|
|
||||||
value_estimate?: number;
|
|
||||||
last_updated: string;
|
|
||||||
|
|
||||||
// Batch information
|
|
||||||
batches?: StockBatch[];
|
|
||||||
oldest_batch_date?: string;
|
|
||||||
newest_batch_date?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface StockBatch {
|
|
||||||
id: string;
|
|
||||||
item_id: string;
|
|
||||||
batch_number?: string;
|
|
||||||
quantity: number;
|
|
||||||
unit_cost?: number;
|
|
||||||
purchase_date?: string;
|
|
||||||
expiration_date?: string;
|
|
||||||
supplier?: string;
|
|
||||||
notes?: string;
|
|
||||||
is_expired: boolean;
|
|
||||||
days_until_expiration?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface StockMovement {
|
|
||||||
id: string;
|
|
||||||
item_id: string;
|
|
||||||
movement_type: StockMovementType;
|
|
||||||
quantity: number;
|
|
||||||
unit_cost?: number;
|
|
||||||
total_cost?: number;
|
|
||||||
batch_id?: string;
|
|
||||||
reference_id?: string;
|
|
||||||
notes?: string;
|
|
||||||
movement_date: string;
|
|
||||||
created_by: string;
|
|
||||||
created_at: string;
|
|
||||||
|
|
||||||
// Related data
|
|
||||||
item_name?: string;
|
|
||||||
batch_info?: StockBatch;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// ========== REQUEST/RESPONSE TYPES ==========
|
|
||||||
|
|
||||||
export interface CreateInventoryItemRequest {
|
|
||||||
name: string;
|
|
||||||
product_type: ProductType;
|
|
||||||
category: IngredientCategory | ProductCategory;
|
|
||||||
unit_of_measure: UnitOfMeasure;
|
|
||||||
estimated_shelf_life_days?: number;
|
|
||||||
requires_refrigeration?: boolean;
|
|
||||||
requires_freezing?: boolean;
|
|
||||||
is_seasonal?: boolean;
|
|
||||||
minimum_stock_level?: number;
|
|
||||||
maximum_stock_level?: number;
|
|
||||||
reorder_point?: number;
|
|
||||||
supplier?: string;
|
|
||||||
notes?: string;
|
|
||||||
barcode?: string;
|
|
||||||
cost_per_unit?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UpdateInventoryItemRequest extends Partial<CreateInventoryItemRequest> {
|
|
||||||
is_active?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface StockAdjustmentRequest {
|
|
||||||
movement_type: StockMovementType;
|
|
||||||
quantity: number;
|
|
||||||
unit_cost?: number;
|
|
||||||
batch_number?: string;
|
|
||||||
expiration_date?: string;
|
|
||||||
supplier?: string;
|
|
||||||
notes?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface InventorySearchParams {
|
|
||||||
search?: string;
|
|
||||||
product_type?: ProductType;
|
|
||||||
category?: string;
|
|
||||||
is_active?: boolean;
|
|
||||||
low_stock_only?: boolean;
|
|
||||||
expiring_soon_only?: boolean;
|
|
||||||
page?: number;
|
|
||||||
limit?: number;
|
|
||||||
sort_by?: 'name' | 'category' | 'stock_level' | 'last_movement' | 'created_at';
|
|
||||||
sort_order?: 'asc' | 'desc';
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface StockMovementSearchParams {
|
|
||||||
item_id?: string;
|
|
||||||
movement_type?: StockMovementType;
|
|
||||||
date_from?: string;
|
|
||||||
date_to?: string;
|
|
||||||
page?: number;
|
|
||||||
limit?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface InventoryDashboardData {
|
|
||||||
total_items: number;
|
|
||||||
total_value: number;
|
|
||||||
low_stock_count: number;
|
|
||||||
expiring_soon_count: number;
|
|
||||||
recent_movements: StockMovement[];
|
|
||||||
top_items_by_value: InventoryItem[];
|
|
||||||
category_breakdown: {
|
|
||||||
category: string;
|
|
||||||
count: number;
|
|
||||||
value: number;
|
|
||||||
}[];
|
|
||||||
movement_trends: {
|
|
||||||
date: string;
|
|
||||||
purchases: number;
|
|
||||||
consumption: number;
|
|
||||||
waste: number;
|
|
||||||
}[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PaginatedResponse<T> {
|
|
||||||
items: T[];
|
|
||||||
total: number;
|
|
||||||
page: number;
|
|
||||||
limit: number;
|
|
||||||
total_pages: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========== INVENTORY SERVICE CLASS ==========
|
|
||||||
|
|
||||||
export class InventoryService {
|
|
||||||
private baseEndpoint = '';
|
|
||||||
|
|
||||||
// ========== INVENTORY ITEMS ==========
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get inventory items with filtering and pagination
|
|
||||||
*/
|
|
||||||
async getInventoryItems(
|
|
||||||
tenantId: string,
|
|
||||||
params?: InventorySearchParams
|
|
||||||
): Promise<PaginatedResponse<InventoryItem>> {
|
|
||||||
const searchParams = new URLSearchParams();
|
|
||||||
|
|
||||||
if (params) {
|
|
||||||
Object.entries(params).forEach(([key, value]) => {
|
|
||||||
if (value !== undefined && value !== null) {
|
|
||||||
searchParams.append(key, value.toString());
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const query = searchParams.toString();
|
|
||||||
const url = `/tenants/${tenantId}/ingredients${query ? `?${query}` : ''}`;
|
|
||||||
|
|
||||||
console.log('🔍 InventoryService: Fetching inventory items from:', url);
|
|
||||||
|
|
||||||
try {
|
|
||||||
console.log('🔑 InventoryService: Making request with auth token:', localStorage.getItem('auth_token') ? 'Present' : 'Missing');
|
|
||||||
const response = await apiClient.get(url);
|
|
||||||
console.log('📋 InventoryService: Raw response:', response);
|
|
||||||
console.log('📋 InventoryService: Response type:', typeof response);
|
|
||||||
console.log('📋 InventoryService: Response keys:', response ? Object.keys(response) : 'null');
|
|
||||||
|
|
||||||
// Handle different response formats
|
|
||||||
if (Array.isArray(response)) {
|
|
||||||
// Direct array response
|
|
||||||
console.log('✅ InventoryService: Array response with', response.length, 'items');
|
|
||||||
return {
|
|
||||||
items: response,
|
|
||||||
total: response.length,
|
|
||||||
page: 1,
|
|
||||||
limit: response.length,
|
|
||||||
total_pages: 1
|
|
||||||
};
|
|
||||||
} else if (response && typeof response === 'object') {
|
|
||||||
// Check if it's already paginated
|
|
||||||
if ('items' in response && Array.isArray(response.items)) {
|
|
||||||
console.log('✅ InventoryService: Paginated response with', response.items.length, 'items');
|
|
||||||
return response;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle object with numeric keys (convert to array)
|
|
||||||
const keys = Object.keys(response);
|
|
||||||
if (keys.length > 0 && keys.every(key => !isNaN(Number(key)))) {
|
|
||||||
const items = Object.values(response);
|
|
||||||
console.log('✅ InventoryService: Numeric keys response with', items.length, 'items');
|
|
||||||
return {
|
|
||||||
items,
|
|
||||||
total: items.length,
|
|
||||||
page: 1,
|
|
||||||
limit: items.length,
|
|
||||||
total_pages: 1
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle empty object - this seems to be what we're getting
|
|
||||||
if (keys.length === 0) {
|
|
||||||
console.log('📭 InventoryService: Empty object response - backend has no inventory items for this tenant');
|
|
||||||
throw new Error('NO_INVENTORY_ITEMS'); // This will trigger fallback in useInventory
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback: unexpected response format
|
|
||||||
console.warn('⚠️ InventoryService: Unexpected response format, keys:', Object.keys(response || {}));
|
|
||||||
throw new Error('UNEXPECTED_RESPONSE_FORMAT');
|
|
||||||
} catch (error) {
|
|
||||||
console.error('❌ InventoryService: Failed to fetch inventory items:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get single inventory item by ID
|
|
||||||
*/
|
|
||||||
async getInventoryItem(tenantId: string, itemId: string): Promise<InventoryItem> {
|
|
||||||
return apiClient.get(`/tenants/${tenantId}/ingredients/${itemId}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create new inventory item
|
|
||||||
*/
|
|
||||||
async createInventoryItem(
|
|
||||||
tenantId: string,
|
|
||||||
data: CreateInventoryItemRequest
|
|
||||||
): Promise<InventoryItem> {
|
|
||||||
return apiClient.post(`/tenants/${tenantId}/ingredients`, data);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update existing inventory item
|
|
||||||
*/
|
|
||||||
async updateInventoryItem(
|
|
||||||
tenantId: string,
|
|
||||||
itemId: string,
|
|
||||||
data: UpdateInventoryItemRequest
|
|
||||||
): Promise<InventoryItem> {
|
|
||||||
return apiClient.put(`/tenants/${tenantId}/ingredients/${itemId}`, data);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete inventory item (soft delete)
|
|
||||||
*/
|
|
||||||
async deleteInventoryItem(tenantId: string, itemId: string): Promise<void> {
|
|
||||||
return apiClient.delete(`/tenants/${tenantId}/ingredients/${itemId}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Bulk update inventory items
|
|
||||||
*/
|
|
||||||
async bulkUpdateInventoryItems(
|
|
||||||
tenantId: string,
|
|
||||||
updates: { id: string; data: UpdateInventoryItemRequest }[]
|
|
||||||
): Promise<{ success: number; failed: number; errors: string[] }> {
|
|
||||||
return apiClient.post(`/tenants/${tenantId}/ingredients/bulk-update`, {
|
|
||||||
updates
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========== STOCK MANAGEMENT ==========
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get current stock level for an item
|
|
||||||
*/
|
|
||||||
async getStockLevel(tenantId: string, itemId: string): Promise<StockLevel> {
|
|
||||||
return apiClient.get(`/tenants/${tenantId}/ingredients/${itemId}/stock`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get stock levels for all items
|
|
||||||
*/
|
|
||||||
async getAllStockLevels(tenantId: string): Promise<StockLevel[]> {
|
|
||||||
// TODO: Map to correct endpoint when available
|
|
||||||
return [];
|
|
||||||
// return apiClient.get(`/stock/summary`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Adjust stock level (purchase, consumption, waste, etc.)
|
|
||||||
*/
|
|
||||||
async adjustStock(
|
|
||||||
tenantId: string,
|
|
||||||
itemId: string,
|
|
||||||
adjustment: StockAdjustmentRequest
|
|
||||||
): Promise<StockMovement> {
|
|
||||||
return apiClient.post(
|
|
||||||
`/stock/consume`,
|
|
||||||
adjustment
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Bulk stock adjustments
|
|
||||||
*/
|
|
||||||
async bulkAdjustStock(
|
|
||||||
tenantId: string,
|
|
||||||
adjustments: { item_id: string; adjustment: StockAdjustmentRequest }[]
|
|
||||||
): Promise<{ success: number; failed: number; movements: StockMovement[]; errors: string[] }> {
|
|
||||||
return apiClient.post(`${this.baseEndpoint}/tenants/${tenantId}/inventory/stock/bulk-adjust`, {
|
|
||||||
adjustments
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get stock movements with filtering
|
|
||||||
*/
|
|
||||||
async getStockMovements(
|
|
||||||
tenantId: string,
|
|
||||||
params?: StockMovementSearchParams
|
|
||||||
): Promise<PaginatedResponse<StockMovement>> {
|
|
||||||
const searchParams = new URLSearchParams();
|
|
||||||
|
|
||||||
if (params) {
|
|
||||||
Object.entries(params).forEach(([key, value]) => {
|
|
||||||
if (value !== undefined && value !== null) {
|
|
||||||
searchParams.append(key, value.toString());
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const query = searchParams.toString();
|
|
||||||
const url = `${this.baseEndpoint}/tenants/${tenantId}/inventory/movements${query ? `?${query}` : ''}`;
|
|
||||||
|
|
||||||
return apiClient.get(url);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// ========== DASHBOARD & ANALYTICS ==========
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get inventory value report
|
|
||||||
*/
|
|
||||||
async getInventoryValue(tenantId: string): Promise<{
|
|
||||||
total_value: number;
|
|
||||||
by_category: { category: string; value: number; percentage: number }[];
|
|
||||||
by_product_type: { type: ProductType; value: number; percentage: number }[];
|
|
||||||
}> {
|
|
||||||
return apiClient.get(`${this.baseEndpoint}/tenants/${tenantId}/inventory/value`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get low stock report
|
|
||||||
*/
|
|
||||||
async getLowStockReport(tenantId: string): Promise<{
|
|
||||||
items: InventoryItem[];
|
|
||||||
total_affected: number;
|
|
||||||
estimated_loss: number;
|
|
||||||
}> {
|
|
||||||
return apiClient.get(`${this.baseEndpoint}/tenants/${tenantId}/inventory/reports/low-stock`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get expiring items report
|
|
||||||
*/
|
|
||||||
async getExpiringItemsReport(tenantId: string, days?: number): Promise<{
|
|
||||||
items: (InventoryItem & { batches: StockBatch[] })[];
|
|
||||||
total_affected: number;
|
|
||||||
estimated_loss: number;
|
|
||||||
}> {
|
|
||||||
const params = days ? `?days=${days}` : '';
|
|
||||||
return apiClient.get(`${this.baseEndpoint}/tenants/${tenantId}/inventory/reports/expiring${params}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========== IMPORT/EXPORT ==========
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Export inventory data to CSV
|
|
||||||
*/
|
|
||||||
async exportInventory(tenantId: string, format: 'csv' | 'excel' = 'csv'): Promise<Blob> {
|
|
||||||
const response = await apiClient.getRaw(
|
|
||||||
`${this.baseEndpoint}/tenants/${tenantId}/inventory/export?format=${format}`
|
|
||||||
);
|
|
||||||
return response.blob();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Import inventory from file
|
|
||||||
*/
|
|
||||||
async importInventory(tenantId: string, file: File): Promise<{
|
|
||||||
success: number;
|
|
||||||
failed: number;
|
|
||||||
errors: string[];
|
|
||||||
created_items: InventoryItem[];
|
|
||||||
}> {
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append('file', file);
|
|
||||||
|
|
||||||
return apiClient.post(`${this.baseEndpoint}/tenants/${tenantId}/inventory/import`, formData, {
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'multipart/form-data',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========== SEARCH & SUGGESTIONS ==========
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Search inventory items with autocomplete
|
|
||||||
*/
|
|
||||||
async searchItems(tenantId: string, query: string, limit = 10): Promise<InventoryItem[]> {
|
|
||||||
return apiClient.get(
|
|
||||||
`${this.baseEndpoint}/tenants/${tenantId}/inventory/search?q=${encodeURIComponent(query)}&limit=${limit}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get category suggestions based on product type
|
|
||||||
*/
|
|
||||||
async getCategorySuggestions(productType: ProductType): Promise<string[]> {
|
|
||||||
return apiClient.get(`${this.baseEndpoint}/inventory/categories?type=${productType}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get supplier suggestions
|
|
||||||
*/
|
|
||||||
async getSupplierSuggestions(tenantId: string): Promise<string[]> {
|
|
||||||
return apiClient.get(`${this.baseEndpoint}/tenants/${tenantId}/inventory/suppliers`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========== PRODUCTS FOR FORECASTING ==========
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get Products List with IDs for Forecasting
|
|
||||||
*/
|
|
||||||
async getProductsList(tenantId: string): Promise<ProductInfo[]> {
|
|
||||||
try {
|
|
||||||
console.log('🔍 Fetching products for forecasting...', { tenantId });
|
|
||||||
|
|
||||||
// First try to get finished products (preferred for forecasting)
|
|
||||||
const response = await apiClient.get(`/tenants/${tenantId}/ingredients`, {
|
|
||||||
params: {
|
|
||||||
limit: 100,
|
|
||||||
product_type: 'finished_product'
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('🔍 Inventory Products API Response:', response);
|
|
||||||
console.log('🔍 Raw response data:', response.data);
|
|
||||||
console.log('🔍 Response status:', response.status);
|
|
||||||
console.log('🔍 Response headers:', response.headers);
|
|
||||||
console.log('🔍 Full response object keys:', Object.keys(response || {}));
|
|
||||||
console.log('🔍 Response data type:', typeof response);
|
|
||||||
console.log('🔍 Response data constructor:', response?.constructor?.name);
|
|
||||||
|
|
||||||
// Check if response.data exists and what type it is
|
|
||||||
if (response && 'data' in response) {
|
|
||||||
console.log('🔍 Response.data exists:', typeof response.data);
|
|
||||||
console.log('🔍 Response.data keys:', Object.keys(response.data || {}));
|
|
||||||
console.log('🔍 Response.data constructor:', response.data?.constructor?.name);
|
|
||||||
}
|
|
||||||
|
|
||||||
let productsArray: any[] = [];
|
|
||||||
|
|
||||||
// Check response.data first (typical API client behavior)
|
|
||||||
const dataToProcess = response?.data || response;
|
|
||||||
|
|
||||||
if (Array.isArray(dataToProcess)) {
|
|
||||||
productsArray = dataToProcess;
|
|
||||||
console.log('✅ Found array data with', productsArray.length, 'items');
|
|
||||||
} else if (dataToProcess && typeof dataToProcess === 'object') {
|
|
||||||
// Handle different response formats
|
|
||||||
const keys = Object.keys(dataToProcess);
|
|
||||||
if (keys.length > 0 && keys.every(key => !isNaN(Number(key)))) {
|
|
||||||
productsArray = Object.values(dataToProcess);
|
|
||||||
console.log('✅ Found object with numeric keys, converted to array with', productsArray.length, 'items');
|
|
||||||
} else {
|
|
||||||
console.warn('⚠️ Response is object but not with numeric keys:', dataToProcess);
|
|
||||||
console.warn('⚠️ Object keys:', keys);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
console.warn('⚠️ Response data is not array or object:', dataToProcess);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert to ProductInfo objects
|
|
||||||
const products: ProductInfo[] = productsArray
|
|
||||||
.map((product: any) => ({
|
|
||||||
inventory_product_id: product.id || product.inventory_product_id,
|
|
||||||
name: product.name || product.product_name || `Product ${product.id || ''}`,
|
|
||||||
category: product.category,
|
|
||||||
// Add additional fields if available from inventory
|
|
||||||
current_stock: product.current_stock,
|
|
||||||
unit: product.unit,
|
|
||||||
cost_per_unit: product.cost_per_unit
|
|
||||||
}))
|
|
||||||
.filter(product => product.inventory_product_id && product.name);
|
|
||||||
|
|
||||||
console.log('📋 Processed finished products:', products);
|
|
||||||
|
|
||||||
// If no finished products found, try to get all products as fallback
|
|
||||||
if (products.length === 0) {
|
|
||||||
console.log('⚠️ No finished products found, trying to get all products as fallback...');
|
|
||||||
|
|
||||||
const fallbackResponse = await apiClient.get(`/tenants/${tenantId}/ingredients`, {
|
|
||||||
params: {
|
|
||||||
limit: 100,
|
|
||||||
// No product_type filter to get all products
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('🔍 Fallback API Response:', fallbackResponse);
|
|
||||||
|
|
||||||
const fallbackDataToProcess = fallbackResponse?.data || fallbackResponse;
|
|
||||||
let fallbackProductsArray: any[] = [];
|
|
||||||
|
|
||||||
if (Array.isArray(fallbackDataToProcess)) {
|
|
||||||
fallbackProductsArray = fallbackDataToProcess;
|
|
||||||
} else if (fallbackDataToProcess && typeof fallbackDataToProcess === 'object') {
|
|
||||||
const keys = Object.keys(fallbackDataToProcess);
|
|
||||||
if (keys.length > 0 && keys.every(key => !isNaN(Number(key)))) {
|
|
||||||
fallbackProductsArray = Object.values(fallbackDataToProcess);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const fallbackProducts: ProductInfo[] = fallbackProductsArray
|
|
||||||
.map((product: any) => ({
|
|
||||||
inventory_product_id: product.id || product.inventory_product_id,
|
|
||||||
name: product.name || product.product_name || `Product ${product.id || ''}`,
|
|
||||||
category: product.category,
|
|
||||||
current_stock: product.current_stock,
|
|
||||||
unit: product.unit,
|
|
||||||
cost_per_unit: product.cost_per_unit
|
|
||||||
}))
|
|
||||||
.filter(product => product.inventory_product_id && product.name);
|
|
||||||
|
|
||||||
console.log('📋 Processed fallback products (all inventory items):', fallbackProducts);
|
|
||||||
return fallbackProducts;
|
|
||||||
}
|
|
||||||
|
|
||||||
return products;
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('❌ Failed to fetch inventory products:', error);
|
|
||||||
console.error('❌ Error details:', {
|
|
||||||
message: error instanceof Error ? error.message : 'Unknown error',
|
|
||||||
response: (error as any)?.response,
|
|
||||||
status: (error as any)?.response?.status,
|
|
||||||
data: (error as any)?.response?.data
|
|
||||||
});
|
|
||||||
|
|
||||||
// If it's an authentication error, throw it to trigger auth flow
|
|
||||||
if ((error as any)?.response?.status === 401) {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Return empty array on other errors - let dashboard handle fallback
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get Product by ID
|
|
||||||
*/
|
|
||||||
async getProductById(tenantId: string, productId: string): Promise<ProductInfo | null> {
|
|
||||||
try {
|
|
||||||
const response = await apiClient.get(`/tenants/${tenantId}/ingredients/${productId}`);
|
|
||||||
|
|
||||||
if (response) {
|
|
||||||
return {
|
|
||||||
inventory_product_id: response.id || response.inventory_product_id,
|
|
||||||
name: response.name || response.product_name,
|
|
||||||
category: response.category,
|
|
||||||
current_stock: response.current_stock,
|
|
||||||
unit: response.unit,
|
|
||||||
cost_per_unit: response.cost_per_unit
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('❌ Failed to fetch product by ID:', error);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========== ENHANCED DASHBOARD FEATURES ==========
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get inventory dashboard data with analytics
|
|
||||||
*/
|
|
||||||
async getDashboardData(tenantId: string, params?: {
|
|
||||||
date_from?: string;
|
|
||||||
date_to?: string;
|
|
||||||
location?: string;
|
|
||||||
}): Promise<{
|
|
||||||
summary: {
|
|
||||||
total_items: number;
|
|
||||||
low_stock_count: number;
|
|
||||||
out_of_stock_items: number;
|
|
||||||
expiring_soon: number;
|
|
||||||
total_value: number;
|
|
||||||
};
|
|
||||||
recent_movements: any[];
|
|
||||||
active_alerts: any[];
|
|
||||||
stock_trends: {
|
|
||||||
dates: string[];
|
|
||||||
stock_levels: number[];
|
|
||||||
movements_in: number[];
|
|
||||||
movements_out: number[];
|
|
||||||
};
|
|
||||||
}> {
|
|
||||||
try {
|
|
||||||
return await apiClient.get(`/tenants/${tenantId}/inventory/dashboard`, { params });
|
|
||||||
} catch (error) {
|
|
||||||
console.error('❌ Error fetching inventory dashboard:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get food safety compliance data
|
|
||||||
*/
|
|
||||||
async getFoodSafetyCompliance(tenantId: string): Promise<{
|
|
||||||
compliant_items: number;
|
|
||||||
non_compliant_items: number;
|
|
||||||
expiring_items: any[];
|
|
||||||
temperature_violations: any[];
|
|
||||||
compliance_score: number;
|
|
||||||
}> {
|
|
||||||
try {
|
|
||||||
return await apiClient.get(`/tenants/${tenantId}/inventory/food-safety/compliance`);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('❌ Error fetching food safety compliance:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get temperature monitoring data
|
|
||||||
*/
|
|
||||||
async getTemperatureMonitoring(tenantId: string, params?: {
|
|
||||||
item_id?: string;
|
|
||||||
location?: string;
|
|
||||||
date_from?: string;
|
|
||||||
date_to?: string;
|
|
||||||
}): Promise<{
|
|
||||||
readings: any[];
|
|
||||||
violations: any[];
|
|
||||||
}> {
|
|
||||||
try {
|
|
||||||
return await apiClient.get(`/tenants/${tenantId}/inventory/food-safety/temperature-monitoring`, { params });
|
|
||||||
} catch (error) {
|
|
||||||
console.error('❌ Error fetching temperature monitoring:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Record temperature reading
|
|
||||||
*/
|
|
||||||
async recordTemperatureReading(tenantId: string, params: {
|
|
||||||
item_id: string;
|
|
||||||
temperature: number;
|
|
||||||
humidity?: number;
|
|
||||||
location: string;
|
|
||||||
notes?: string;
|
|
||||||
}): Promise<void> {
|
|
||||||
try {
|
|
||||||
return await apiClient.post(`/tenants/${tenantId}/inventory/food-safety/temperature-reading`, params);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('❌ Error recording temperature reading:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get restock recommendations
|
|
||||||
*/
|
|
||||||
async getRestockRecommendations(tenantId: string): Promise<{
|
|
||||||
urgent_restocks: any[];
|
|
||||||
optimal_orders: any[];
|
|
||||||
}> {
|
|
||||||
try {
|
|
||||||
return await apiClient.get(`/tenants/${tenantId}/inventory/forecasting/restock-recommendations`);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('❌ Error fetching restock recommendations:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const inventoryService = new InventoryService();
|
|
||||||
@@ -1,185 +0,0 @@
|
|||||||
// frontend/src/api/services/notification.service.ts
|
|
||||||
/**
|
|
||||||
* Notification Service
|
|
||||||
* Handles notification operations
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { apiClient } from '../client';
|
|
||||||
import type {
|
|
||||||
NotificationCreate,
|
|
||||||
NotificationResponse,
|
|
||||||
NotificationTemplate,
|
|
||||||
NotificationHistory,
|
|
||||||
NotificationStats,
|
|
||||||
BulkNotificationRequest,
|
|
||||||
BulkNotificationStatus,
|
|
||||||
PaginatedResponse,
|
|
||||||
BaseQueryParams,
|
|
||||||
} from '../types';
|
|
||||||
|
|
||||||
export class NotificationService {
|
|
||||||
/**
|
|
||||||
* Send Notification
|
|
||||||
*/
|
|
||||||
async sendNotification(
|
|
||||||
tenantId: string,
|
|
||||||
notification: NotificationCreate
|
|
||||||
): Promise<NotificationResponse> {
|
|
||||||
return apiClient.post(`/tenants/${tenantId}/notifications`, notification);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Send Bulk Notifications
|
|
||||||
*/
|
|
||||||
async sendBulkNotifications(
|
|
||||||
tenantId: string,
|
|
||||||
request: BulkNotificationRequest
|
|
||||||
): Promise<BulkNotificationStatus> {
|
|
||||||
return apiClient.post(`/tenants/${tenantId}/notifications/bulk`, request);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get Notifications
|
|
||||||
*/
|
|
||||||
async getNotifications(
|
|
||||||
tenantId: string,
|
|
||||||
params?: BaseQueryParams & {
|
|
||||||
channel?: string;
|
|
||||||
status?: string;
|
|
||||||
recipient_email?: string;
|
|
||||||
start_date?: string;
|
|
||||||
end_date?: string;
|
|
||||||
}
|
|
||||||
): Promise<PaginatedResponse<NotificationResponse>> {
|
|
||||||
return apiClient.get(`/tenants/${tenantId}/notifications`, { params });
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get Notification by ID
|
|
||||||
*/
|
|
||||||
async getNotification(tenantId: string, notificationId: string): Promise<NotificationResponse> {
|
|
||||||
return apiClient.get(`/tenants/${tenantId}/notifications/${notificationId}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get Notification History
|
|
||||||
*/
|
|
||||||
async getNotificationHistory(
|
|
||||||
tenantId: string,
|
|
||||||
notificationId: string
|
|
||||||
): Promise<NotificationHistory[]> {
|
|
||||||
return apiClient.get(`/tenants/${tenantId}/notifications/${notificationId}/history`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Cancel Scheduled Notification
|
|
||||||
*/
|
|
||||||
async cancelNotification(
|
|
||||||
tenantId: string,
|
|
||||||
notificationId: string
|
|
||||||
): Promise<{ message: string }> {
|
|
||||||
return apiClient.post(`/tenants/${tenantId}/notifications/${notificationId}/cancel`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get Bulk Notification Status
|
|
||||||
*/
|
|
||||||
async getBulkNotificationStatus(
|
|
||||||
tenantId: string,
|
|
||||||
batchId: string
|
|
||||||
): Promise<BulkNotificationStatus> {
|
|
||||||
return apiClient.get(`/tenants/${tenantId}/notifications/bulk/${batchId}/status`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get Notification Templates
|
|
||||||
*/
|
|
||||||
async getTemplates(
|
|
||||||
tenantId: string,
|
|
||||||
params?: BaseQueryParams & {
|
|
||||||
channel?: string;
|
|
||||||
is_active?: boolean;
|
|
||||||
}
|
|
||||||
): Promise<PaginatedResponse<NotificationTemplate>> {
|
|
||||||
return apiClient.get(`/tenants/${tenantId}/notifications/templates`, { params });
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create Notification Template
|
|
||||||
*/
|
|
||||||
async createTemplate(
|
|
||||||
tenantId: string,
|
|
||||||
template: Omit<NotificationTemplate, 'id' | 'tenant_id' | 'created_at' | 'updated_at'>
|
|
||||||
): Promise<NotificationTemplate> {
|
|
||||||
return apiClient.post(`/tenants/${tenantId}/notifications/templates`, template);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update Notification Template
|
|
||||||
*/
|
|
||||||
async updateTemplate(
|
|
||||||
tenantId: string,
|
|
||||||
templateId: string,
|
|
||||||
template: Partial<NotificationTemplate>
|
|
||||||
): Promise<NotificationTemplate> {
|
|
||||||
return apiClient.put(`/tenants/${tenantId}/notifications/templates/${templateId}`, template);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete Notification Template
|
|
||||||
*/
|
|
||||||
async deleteTemplate(tenantId: string, templateId: string): Promise<{ message: string }> {
|
|
||||||
return apiClient.delete(`/tenants/${tenantId}/notifications/templates/${templateId}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get Notification Statistics
|
|
||||||
*/
|
|
||||||
async getNotificationStats(
|
|
||||||
tenantId: string,
|
|
||||||
params?: {
|
|
||||||
start_date?: string;
|
|
||||||
end_date?: string;
|
|
||||||
channel?: string;
|
|
||||||
}
|
|
||||||
): Promise<NotificationStats> {
|
|
||||||
return apiClient.get(`/tenants/${tenantId}/notifications/stats`, { params });
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Test Notification Configuration
|
|
||||||
*/
|
|
||||||
async testNotificationConfig(
|
|
||||||
tenantId: string,
|
|
||||||
config: {
|
|
||||||
channel: string;
|
|
||||||
recipient: string;
|
|
||||||
test_message: string;
|
|
||||||
}
|
|
||||||
): Promise<{ success: boolean; message: string }> {
|
|
||||||
return apiClient.post(`/tenants/${tenantId}/notifications/test`, config);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get User Notification Preferences
|
|
||||||
*/
|
|
||||||
async getUserPreferences(tenantId: string, userId: string): Promise<Record<string, boolean>> {
|
|
||||||
return apiClient.get(`/tenants/${tenantId}/notifications/preferences/${userId}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update User Notification Preferences
|
|
||||||
*/
|
|
||||||
async updateUserPreferences(
|
|
||||||
tenantId: string,
|
|
||||||
userId: string,
|
|
||||||
preferences: Record<string, boolean>
|
|
||||||
): Promise<{ message: string }> {
|
|
||||||
return apiClient.put(
|
|
||||||
`/tenants/${tenantId}/notifications/preferences/${userId}`,
|
|
||||||
preferences
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const notificationService = new NotificationService();
|
|
||||||
@@ -1,288 +0,0 @@
|
|||||||
// frontend/src/api/services/onboarding.service.ts
|
|
||||||
/**
|
|
||||||
* Onboarding Service
|
|
||||||
* Handles user progress tracking and onboarding flow management
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { apiClient } from '../client';
|
|
||||||
|
|
||||||
export interface OnboardingStepStatus {
|
|
||||||
step_name: string;
|
|
||||||
completed: boolean;
|
|
||||||
completed_at?: string;
|
|
||||||
data?: Record<string, any>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UserProgress {
|
|
||||||
user_id: string;
|
|
||||||
steps: OnboardingStepStatus[];
|
|
||||||
current_step: string;
|
|
||||||
next_step?: string;
|
|
||||||
completion_percentage: number;
|
|
||||||
fully_completed: boolean;
|
|
||||||
last_updated: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UpdateStepRequest {
|
|
||||||
step_name: string;
|
|
||||||
completed: boolean;
|
|
||||||
data?: Record<string, any>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface InventorySuggestion {
|
|
||||||
suggestion_id: string;
|
|
||||||
original_name: string;
|
|
||||||
suggested_name: string;
|
|
||||||
product_type: 'ingredient' | 'finished_product';
|
|
||||||
category: string;
|
|
||||||
unit_of_measure: string;
|
|
||||||
confidence_score: number;
|
|
||||||
estimated_shelf_life_days?: number;
|
|
||||||
requires_refrigeration: boolean;
|
|
||||||
requires_freezing: boolean;
|
|
||||||
is_seasonal: boolean;
|
|
||||||
suggested_supplier?: string;
|
|
||||||
notes?: string;
|
|
||||||
user_approved?: boolean;
|
|
||||||
user_modifications?: Record<string, any>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BusinessModelAnalysis {
|
|
||||||
model: 'production' | 'retail' | 'hybrid';
|
|
||||||
confidence: number;
|
|
||||||
ingredient_count: number;
|
|
||||||
finished_product_count: number;
|
|
||||||
ingredient_ratio: number;
|
|
||||||
recommendations: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 1: File validation result
|
|
||||||
export interface FileValidationResult {
|
|
||||||
is_valid: boolean;
|
|
||||||
total_records: number;
|
|
||||||
unique_products: number;
|
|
||||||
product_list: string[];
|
|
||||||
validation_errors: any[];
|
|
||||||
validation_warnings: any[];
|
|
||||||
summary: Record<string, any>;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 2: AI suggestions result
|
|
||||||
export interface ProductSuggestionsResult {
|
|
||||||
suggestions: InventorySuggestion[];
|
|
||||||
business_model_analysis: BusinessModelAnalysis;
|
|
||||||
total_products: number;
|
|
||||||
high_confidence_count: number;
|
|
||||||
low_confidence_count: number;
|
|
||||||
processing_time_seconds: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Legacy support - will be deprecated
|
|
||||||
export interface OnboardingAnalysisResult {
|
|
||||||
total_products_found: number;
|
|
||||||
inventory_suggestions: InventorySuggestion[];
|
|
||||||
business_model_analysis: BusinessModelAnalysis;
|
|
||||||
import_job_id: string;
|
|
||||||
status: string;
|
|
||||||
processed_rows: number;
|
|
||||||
errors: string[];
|
|
||||||
warnings: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface InventoryCreationResult {
|
|
||||||
created_items: any[];
|
|
||||||
failed_items: any[];
|
|
||||||
total_approved: number;
|
|
||||||
success_rate: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SalesImportResult {
|
|
||||||
import_job_id: string;
|
|
||||||
status: string;
|
|
||||||
processed_rows: number;
|
|
||||||
successful_imports: number;
|
|
||||||
failed_imports: number;
|
|
||||||
errors: string[];
|
|
||||||
warnings: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export class OnboardingService {
|
|
||||||
private baseEndpoint = '/users/me/onboarding';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get user's current onboarding progress
|
|
||||||
*/
|
|
||||||
async getUserProgress(): Promise<UserProgress> {
|
|
||||||
return apiClient.get(`${this.baseEndpoint}/progress`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update a specific onboarding step
|
|
||||||
*/
|
|
||||||
async updateStep(data: UpdateStepRequest): Promise<UserProgress> {
|
|
||||||
return apiClient.put(`${this.baseEndpoint}/step`, data);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Mark step as completed with optional data
|
|
||||||
*/
|
|
||||||
async completeStep(stepName: string, data?: Record<string, any>): Promise<UserProgress> {
|
|
||||||
return this.updateStep({
|
|
||||||
step_name: stepName,
|
|
||||||
completed: true,
|
|
||||||
data
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reset a step (mark as incomplete)
|
|
||||||
*/
|
|
||||||
async resetStep(stepName: string): Promise<UserProgress> {
|
|
||||||
return this.updateStep({
|
|
||||||
step_name: stepName,
|
|
||||||
completed: false
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get next required step for user
|
|
||||||
*/
|
|
||||||
async getNextStep(): Promise<{ step: string; data?: Record<string, any> }> {
|
|
||||||
return apiClient.get(`${this.baseEndpoint}/next-step`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Complete entire onboarding process
|
|
||||||
*/
|
|
||||||
async completeOnboarding(): Promise<{ success: boolean; message: string }> {
|
|
||||||
return apiClient.post(`${this.baseEndpoint}/complete`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if user can access a specific step
|
|
||||||
*/
|
|
||||||
async canAccessStep(stepName: string): Promise<{ can_access: boolean; reason?: string }> {
|
|
||||||
return apiClient.get(`${this.baseEndpoint}/can-access/${stepName}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========== NEW 4-STEP AUTOMATED INVENTORY CREATION METHODS ==========
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Step 1: Validate file and extract unique products
|
|
||||||
*/
|
|
||||||
async validateFileAndExtractProducts(tenantId: string, file: File): Promise<FileValidationResult> {
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append('file', file);
|
|
||||||
|
|
||||||
return apiClient.post(`/tenants/${tenantId}/onboarding/validate-file`, formData, {
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'multipart/form-data',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Step 2: Generate AI-powered inventory suggestions
|
|
||||||
*/
|
|
||||||
async generateInventorySuggestions(
|
|
||||||
tenantId: string,
|
|
||||||
file: File,
|
|
||||||
productList: string[]
|
|
||||||
): Promise<ProductSuggestionsResult> {
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append('file', file);
|
|
||||||
formData.append('product_list', JSON.stringify(productList));
|
|
||||||
|
|
||||||
return apiClient.post(`/tenants/${tenantId}/onboarding/generate-suggestions`, formData, {
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'multipart/form-data',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Step 3: Create inventory from approved suggestions
|
|
||||||
*/
|
|
||||||
async createInventoryFromSuggestions(
|
|
||||||
tenantId: string,
|
|
||||||
suggestions: InventorySuggestion[]
|
|
||||||
): Promise<InventoryCreationResult> {
|
|
||||||
return apiClient.post(`/tenants/${tenantId}/onboarding/create-inventory`, {
|
|
||||||
suggestions: suggestions.map(s => ({
|
|
||||||
suggestion_id: s.suggestion_id,
|
|
||||||
approved: s.user_approved ?? true,
|
|
||||||
modifications: s.user_modifications || {},
|
|
||||||
// Include full suggestion data for backend processing
|
|
||||||
original_name: s.original_name,
|
|
||||||
suggested_name: s.suggested_name,
|
|
||||||
product_type: s.product_type,
|
|
||||||
category: s.category,
|
|
||||||
unit_of_measure: s.unit_of_measure,
|
|
||||||
confidence_score: s.confidence_score,
|
|
||||||
estimated_shelf_life_days: s.estimated_shelf_life_days,
|
|
||||||
requires_refrigeration: s.requires_refrigeration,
|
|
||||||
requires_freezing: s.requires_freezing,
|
|
||||||
is_seasonal: s.is_seasonal,
|
|
||||||
suggested_supplier: s.suggested_supplier,
|
|
||||||
notes: s.notes
|
|
||||||
}))
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Step 4: Final sales data import with inventory mapping
|
|
||||||
*/
|
|
||||||
async importSalesWithInventory(
|
|
||||||
tenantId: string,
|
|
||||||
file: File,
|
|
||||||
inventoryMapping: Record<string, string>
|
|
||||||
): Promise<SalesImportResult> {
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append('file', file);
|
|
||||||
formData.append('inventory_mapping', JSON.stringify(inventoryMapping));
|
|
||||||
|
|
||||||
return apiClient.post(`/tenants/${tenantId}/onboarding/import-sales`, formData, {
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'multipart/form-data',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========== LEGACY METHODS (for backward compatibility) ==========
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @deprecated Use the new 4-step flow instead
|
|
||||||
* Phase 1: Analyze sales data and get AI suggestions (OLD METHOD)
|
|
||||||
*/
|
|
||||||
async analyzeSalesDataForOnboarding(tenantId: string, file: File): Promise<OnboardingAnalysisResult> {
|
|
||||||
// This method will use the new flow under the hood for backward compatibility
|
|
||||||
const validationResult = await this.validateFileAndExtractProducts(tenantId, file);
|
|
||||||
|
|
||||||
if (!validationResult.is_valid) {
|
|
||||||
throw new Error(`File validation failed: ${validationResult.validation_errors.map(e => e.message || e).join(', ')}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const suggestionsResult = await this.generateInventorySuggestions(tenantId, file, validationResult.product_list);
|
|
||||||
|
|
||||||
// Convert to legacy format
|
|
||||||
return {
|
|
||||||
total_products_found: suggestionsResult.total_products,
|
|
||||||
inventory_suggestions: suggestionsResult.suggestions,
|
|
||||||
business_model_analysis: suggestionsResult.business_model_analysis,
|
|
||||||
import_job_id: `legacy-${Date.now()}`,
|
|
||||||
status: 'completed',
|
|
||||||
processed_rows: validationResult.total_records,
|
|
||||||
errors: validationResult.validation_errors.map(e => e.message || String(e)),
|
|
||||||
warnings: validationResult.validation_warnings.map(w => w.message || String(w))
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get business model guidance based on analysis
|
|
||||||
*/
|
|
||||||
async getBusinessModelGuide(tenantId: string, model: string): Promise<any> {
|
|
||||||
return apiClient.get(`/tenants/${tenantId}/onboarding/business-model-guide?model=${model}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const onboardingService = new OnboardingService();
|
|
||||||
@@ -1,349 +0,0 @@
|
|||||||
// ================================================================
|
|
||||||
// frontend/src/api/services/orders.service.ts
|
|
||||||
// ================================================================
|
|
||||||
/**
|
|
||||||
* Orders Service - API client for Orders Service endpoints
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { apiClient } from '../client';
|
|
||||||
|
|
||||||
// Order Types
|
|
||||||
export interface Order {
|
|
||||||
id: string;
|
|
||||||
tenant_id: string;
|
|
||||||
customer_id?: string;
|
|
||||||
customer_name?: string;
|
|
||||||
customer_email?: string;
|
|
||||||
customer_phone?: string;
|
|
||||||
order_number: string;
|
|
||||||
status: 'pending' | 'confirmed' | 'in_production' | 'ready' | 'delivered' | 'cancelled';
|
|
||||||
order_type: 'walk_in' | 'online' | 'phone' | 'catering';
|
|
||||||
business_model: 'individual_bakery' | 'central_bakery';
|
|
||||||
items: OrderItem[];
|
|
||||||
subtotal: number;
|
|
||||||
tax_amount: number;
|
|
||||||
discount_amount: number;
|
|
||||||
total_amount: number;
|
|
||||||
delivery_date?: string;
|
|
||||||
delivery_address?: string;
|
|
||||||
notes?: string;
|
|
||||||
created_at: string;
|
|
||||||
updated_at: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface OrderItem {
|
|
||||||
id: string;
|
|
||||||
recipe_id: string;
|
|
||||||
recipe_name: string;
|
|
||||||
quantity: number;
|
|
||||||
unit_price: number;
|
|
||||||
total_price: number;
|
|
||||||
customizations?: Record<string, any>;
|
|
||||||
production_notes?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Customer {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
email?: string;
|
|
||||||
phone?: string;
|
|
||||||
address?: string;
|
|
||||||
customer_type: 'individual' | 'business' | 'catering';
|
|
||||||
preferences?: string[];
|
|
||||||
loyalty_points?: number;
|
|
||||||
total_orders: number;
|
|
||||||
total_spent: number;
|
|
||||||
created_at: string;
|
|
||||||
updated_at: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface OrderDashboardData {
|
|
||||||
summary: {
|
|
||||||
total_orders_today: number;
|
|
||||||
pending_orders: number;
|
|
||||||
orders_in_production: number;
|
|
||||||
completed_orders: number;
|
|
||||||
revenue_today: number;
|
|
||||||
average_order_value: number;
|
|
||||||
};
|
|
||||||
recent_orders: Order[];
|
|
||||||
peak_hours: { hour: number; orders: number }[];
|
|
||||||
popular_items: { recipe_name: string; quantity: number }[];
|
|
||||||
business_model_distribution: { model: string; count: number; revenue: number }[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ProcurementPlan {
|
|
||||||
id: string;
|
|
||||||
date: string;
|
|
||||||
status: 'draft' | 'approved' | 'ordered' | 'completed';
|
|
||||||
total_cost: number;
|
|
||||||
items: ProcurementItem[];
|
|
||||||
supplier_orders: SupplierOrder[];
|
|
||||||
created_at: string;
|
|
||||||
updated_at: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ProcurementItem {
|
|
||||||
ingredient_id: string;
|
|
||||||
ingredient_name: string;
|
|
||||||
required_quantity: number;
|
|
||||||
current_stock: number;
|
|
||||||
quantity_to_order: number;
|
|
||||||
unit: string;
|
|
||||||
estimated_cost: number;
|
|
||||||
priority: 'low' | 'medium' | 'high' | 'critical';
|
|
||||||
supplier_id?: string;
|
|
||||||
supplier_name?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SupplierOrder {
|
|
||||||
supplier_id: string;
|
|
||||||
supplier_name: string;
|
|
||||||
items: ProcurementItem[];
|
|
||||||
total_cost: number;
|
|
||||||
delivery_date?: string;
|
|
||||||
notes?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface OrderCreateRequest {
|
|
||||||
customer_id?: string;
|
|
||||||
customer_name?: string;
|
|
||||||
customer_email?: string;
|
|
||||||
customer_phone?: string;
|
|
||||||
order_type: 'walk_in' | 'online' | 'phone' | 'catering';
|
|
||||||
business_model: 'individual_bakery' | 'central_bakery';
|
|
||||||
items: {
|
|
||||||
recipe_id: string;
|
|
||||||
quantity: number;
|
|
||||||
customizations?: Record<string, any>;
|
|
||||||
}[];
|
|
||||||
delivery_date?: string;
|
|
||||||
delivery_address?: string;
|
|
||||||
notes?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface OrderUpdateRequest {
|
|
||||||
status?: 'pending' | 'confirmed' | 'in_production' | 'ready' | 'delivered' | 'cancelled';
|
|
||||||
items?: {
|
|
||||||
recipe_id: string;
|
|
||||||
quantity: number;
|
|
||||||
customizations?: Record<string, any>;
|
|
||||||
}[];
|
|
||||||
delivery_date?: string;
|
|
||||||
delivery_address?: string;
|
|
||||||
notes?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class OrdersService {
|
|
||||||
private readonly basePath = '/orders';
|
|
||||||
|
|
||||||
// Dashboard
|
|
||||||
async getDashboardData(params?: {
|
|
||||||
date_from?: string;
|
|
||||||
date_to?: string;
|
|
||||||
}): Promise<OrderDashboardData> {
|
|
||||||
return apiClient.get(`${this.basePath}/dashboard`, { params });
|
|
||||||
}
|
|
||||||
|
|
||||||
async getDashboardMetrics(params?: {
|
|
||||||
date_from?: string;
|
|
||||||
date_to?: string;
|
|
||||||
granularity?: 'hour' | 'day' | 'week' | 'month';
|
|
||||||
}): Promise<{
|
|
||||||
dates: string[];
|
|
||||||
order_counts: number[];
|
|
||||||
revenue: number[];
|
|
||||||
average_order_values: number[];
|
|
||||||
business_model_breakdown: { model: string; orders: number[]; revenue: number[] }[];
|
|
||||||
}> {
|
|
||||||
return apiClient.get(`${this.basePath}/dashboard/metrics`, { params });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Orders
|
|
||||||
async getOrders(params?: {
|
|
||||||
status?: string;
|
|
||||||
order_type?: string;
|
|
||||||
business_model?: string;
|
|
||||||
customer_id?: string;
|
|
||||||
date_from?: string;
|
|
||||||
date_to?: string;
|
|
||||||
limit?: number;
|
|
||||||
offset?: number;
|
|
||||||
}): Promise<Order[]> {
|
|
||||||
return apiClient.get(`${this.basePath}`, { params });
|
|
||||||
}
|
|
||||||
|
|
||||||
async getOrder(orderId: string): Promise<Order> {
|
|
||||||
return apiClient.get(`${this.basePath}/${orderId}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
async createOrder(order: OrderCreateRequest): Promise<Order> {
|
|
||||||
return apiClient.post(`${this.basePath}`, order);
|
|
||||||
}
|
|
||||||
|
|
||||||
async updateOrder(orderId: string, updates: OrderUpdateRequest): Promise<Order> {
|
|
||||||
return apiClient.put(`${this.basePath}/${orderId}`, updates);
|
|
||||||
}
|
|
||||||
|
|
||||||
async deleteOrder(orderId: string): Promise<void> {
|
|
||||||
return apiClient.delete(`${this.basePath}/${orderId}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
async updateOrderStatus(orderId: string, status: Order['status']): Promise<Order> {
|
|
||||||
return apiClient.patch(`${this.basePath}/${orderId}/status`, { status });
|
|
||||||
}
|
|
||||||
|
|
||||||
async getOrderHistory(orderId: string): Promise<{
|
|
||||||
order: Order;
|
|
||||||
status_changes: {
|
|
||||||
status: string;
|
|
||||||
timestamp: string;
|
|
||||||
user: string;
|
|
||||||
notes?: string
|
|
||||||
}[];
|
|
||||||
}> {
|
|
||||||
return apiClient.get(`${this.basePath}/${orderId}/history`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Customers
|
|
||||||
async getCustomers(params?: {
|
|
||||||
search?: string;
|
|
||||||
customer_type?: string;
|
|
||||||
limit?: number;
|
|
||||||
offset?: number;
|
|
||||||
}): Promise<Customer[]> {
|
|
||||||
return apiClient.get(`${this.basePath}/customers`, { params });
|
|
||||||
}
|
|
||||||
|
|
||||||
async getCustomer(customerId: string): Promise<Customer> {
|
|
||||||
return apiClient.get(`${this.basePath}/customers/${customerId}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
async createCustomer(customer: {
|
|
||||||
name: string;
|
|
||||||
email?: string;
|
|
||||||
phone?: string;
|
|
||||||
address?: string;
|
|
||||||
customer_type: 'individual' | 'business' | 'catering';
|
|
||||||
preferences?: string[];
|
|
||||||
}): Promise<Customer> {
|
|
||||||
return apiClient.post(`${this.basePath}/customers`, customer);
|
|
||||||
}
|
|
||||||
|
|
||||||
async updateCustomer(customerId: string, updates: {
|
|
||||||
name?: string;
|
|
||||||
email?: string;
|
|
||||||
phone?: string;
|
|
||||||
address?: string;
|
|
||||||
customer_type?: 'individual' | 'business' | 'catering';
|
|
||||||
preferences?: string[];
|
|
||||||
}): Promise<Customer> {
|
|
||||||
return apiClient.put(`${this.basePath}/customers/${customerId}`, updates);
|
|
||||||
}
|
|
||||||
|
|
||||||
async getCustomerOrders(customerId: string, params?: {
|
|
||||||
limit?: number;
|
|
||||||
offset?: number;
|
|
||||||
}): Promise<Order[]> {
|
|
||||||
return apiClient.get(`${this.basePath}/customers/${customerId}/orders`, { params });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Procurement Planning
|
|
||||||
async getProcurementPlans(params?: {
|
|
||||||
status?: string;
|
|
||||||
date_from?: string;
|
|
||||||
date_to?: string;
|
|
||||||
limit?: number;
|
|
||||||
offset?: number;
|
|
||||||
}): Promise<ProcurementPlan[]> {
|
|
||||||
return apiClient.get(`${this.basePath}/procurement/plans`, { params });
|
|
||||||
}
|
|
||||||
|
|
||||||
async getProcurementPlan(planId: string): Promise<ProcurementPlan> {
|
|
||||||
return apiClient.get(`${this.basePath}/procurement/plans/${planId}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
async createProcurementPlan(params: {
|
|
||||||
date: string;
|
|
||||||
orders?: string[];
|
|
||||||
forecast_days?: number;
|
|
||||||
}): Promise<ProcurementPlan> {
|
|
||||||
return apiClient.post(`${this.basePath}/procurement/plans`, params);
|
|
||||||
}
|
|
||||||
|
|
||||||
async updateProcurementPlan(planId: string, updates: {
|
|
||||||
items?: ProcurementItem[];
|
|
||||||
notes?: string;
|
|
||||||
}): Promise<ProcurementPlan> {
|
|
||||||
return apiClient.put(`${this.basePath}/procurement/plans/${planId}`, updates);
|
|
||||||
}
|
|
||||||
|
|
||||||
async approveProcurementPlan(planId: string): Promise<ProcurementPlan> {
|
|
||||||
return apiClient.post(`${this.basePath}/procurement/plans/${planId}/approve`);
|
|
||||||
}
|
|
||||||
|
|
||||||
async generateSupplierOrders(planId: string): Promise<SupplierOrder[]> {
|
|
||||||
return apiClient.post(`${this.basePath}/procurement/plans/${planId}/generate-orders`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Business Model Detection
|
|
||||||
async detectBusinessModel(): Promise<{
|
|
||||||
detected_model: 'individual_bakery' | 'central_bakery';
|
|
||||||
confidence: number;
|
|
||||||
factors: {
|
|
||||||
daily_order_volume: number;
|
|
||||||
delivery_ratio: number;
|
|
||||||
catering_ratio: number;
|
|
||||||
average_order_size: number;
|
|
||||||
};
|
|
||||||
recommendations: string[];
|
|
||||||
}> {
|
|
||||||
return apiClient.post(`${this.basePath}/business-model/detect`);
|
|
||||||
}
|
|
||||||
|
|
||||||
async updateBusinessModel(model: 'individual_bakery' | 'central_bakery'): Promise<void> {
|
|
||||||
return apiClient.put(`${this.basePath}/business-model`, { business_model: model });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Analytics
|
|
||||||
async getOrderTrends(params?: {
|
|
||||||
date_from?: string;
|
|
||||||
date_to?: string;
|
|
||||||
granularity?: 'hour' | 'day' | 'week' | 'month';
|
|
||||||
}): Promise<{
|
|
||||||
dates: string[];
|
|
||||||
order_counts: number[];
|
|
||||||
revenue: number[];
|
|
||||||
popular_items: { recipe_name: string; count: number }[];
|
|
||||||
}> {
|
|
||||||
return apiClient.get(`${this.basePath}/analytics/trends`, { params });
|
|
||||||
}
|
|
||||||
|
|
||||||
async getCustomerAnalytics(params?: {
|
|
||||||
date_from?: string;
|
|
||||||
date_to?: string;
|
|
||||||
}): Promise<{
|
|
||||||
new_customers: number;
|
|
||||||
returning_customers: number;
|
|
||||||
customer_retention_rate: number;
|
|
||||||
average_lifetime_value: number;
|
|
||||||
top_customers: Customer[];
|
|
||||||
}> {
|
|
||||||
return apiClient.get(`${this.basePath}/analytics/customers`, { params });
|
|
||||||
}
|
|
||||||
|
|
||||||
async getSeasonalAnalysis(params?: {
|
|
||||||
date_from?: string;
|
|
||||||
date_to?: string;
|
|
||||||
}): Promise<{
|
|
||||||
seasonal_patterns: { month: string; order_count: number; revenue: number }[];
|
|
||||||
weekly_patterns: { day: string; order_count: number }[];
|
|
||||||
hourly_patterns: { hour: number; order_count: number }[];
|
|
||||||
trending_products: { recipe_name: string; growth_rate: number }[];
|
|
||||||
}> {
|
|
||||||
return apiClient.get(`${this.basePath}/analytics/seasonal`, { params });
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,392 +0,0 @@
|
|||||||
// frontend/src/api/services/pos.service.ts
|
|
||||||
/**
|
|
||||||
* POS Integration API Service
|
|
||||||
* Handles all communication with the POS service backend
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { apiClient } from '../client';
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// TYPES & INTERFACES
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
export interface POSConfiguration {
|
|
||||||
id: string;
|
|
||||||
tenant_id: string;
|
|
||||||
pos_system: 'square' | 'toast' | 'lightspeed';
|
|
||||||
provider_name: string;
|
|
||||||
is_active: boolean;
|
|
||||||
is_connected: boolean;
|
|
||||||
environment: 'sandbox' | 'production';
|
|
||||||
location_id?: string;
|
|
||||||
merchant_id?: string;
|
|
||||||
sync_enabled: boolean;
|
|
||||||
sync_interval_minutes: string;
|
|
||||||
auto_sync_products: boolean;
|
|
||||||
auto_sync_transactions: boolean;
|
|
||||||
webhook_url?: string;
|
|
||||||
last_sync_at?: string;
|
|
||||||
last_successful_sync_at?: string;
|
|
||||||
last_sync_status?: 'success' | 'failed' | 'partial';
|
|
||||||
last_sync_message?: string;
|
|
||||||
provider_settings?: Record<string, any>;
|
|
||||||
last_health_check_at?: string;
|
|
||||||
health_status: 'healthy' | 'unhealthy' | 'warning' | 'unknown';
|
|
||||||
health_message?: string;
|
|
||||||
created_at: string;
|
|
||||||
updated_at: string;
|
|
||||||
created_by?: string;
|
|
||||||
notes?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CreatePOSConfigurationRequest {
|
|
||||||
pos_system: 'square' | 'toast' | 'lightspeed';
|
|
||||||
provider_name: string;
|
|
||||||
environment: 'sandbox' | 'production';
|
|
||||||
location_id?: string;
|
|
||||||
merchant_id?: string;
|
|
||||||
sync_enabled?: boolean;
|
|
||||||
sync_interval_minutes?: string;
|
|
||||||
auto_sync_products?: boolean;
|
|
||||||
auto_sync_transactions?: boolean;
|
|
||||||
notes?: string;
|
|
||||||
// Credentials
|
|
||||||
api_key?: string;
|
|
||||||
api_secret?: string;
|
|
||||||
access_token?: string;
|
|
||||||
application_id?: string;
|
|
||||||
webhook_secret?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UpdatePOSConfigurationRequest {
|
|
||||||
provider_name?: string;
|
|
||||||
is_active?: boolean;
|
|
||||||
environment?: 'sandbox' | 'production';
|
|
||||||
location_id?: string;
|
|
||||||
merchant_id?: string;
|
|
||||||
sync_enabled?: boolean;
|
|
||||||
sync_interval_minutes?: string;
|
|
||||||
auto_sync_products?: boolean;
|
|
||||||
auto_sync_transactions?: boolean;
|
|
||||||
notes?: string;
|
|
||||||
// Credentials (only if updating)
|
|
||||||
api_key?: string;
|
|
||||||
api_secret?: string;
|
|
||||||
access_token?: string;
|
|
||||||
application_id?: string;
|
|
||||||
webhook_secret?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface POSTransaction {
|
|
||||||
id: string;
|
|
||||||
tenant_id: string;
|
|
||||||
pos_config_id: string;
|
|
||||||
pos_system: string;
|
|
||||||
external_transaction_id: string;
|
|
||||||
external_order_id?: string;
|
|
||||||
transaction_type: 'sale' | 'refund' | 'void' | 'exchange';
|
|
||||||
status: 'completed' | 'pending' | 'failed' | 'refunded' | 'voided';
|
|
||||||
subtotal: number;
|
|
||||||
tax_amount: number;
|
|
||||||
tip_amount: number;
|
|
||||||
discount_amount: number;
|
|
||||||
total_amount: number;
|
|
||||||
currency: string;
|
|
||||||
payment_method?: string;
|
|
||||||
payment_status?: string;
|
|
||||||
transaction_date: string;
|
|
||||||
pos_created_at: string;
|
|
||||||
pos_updated_at?: string;
|
|
||||||
location_id?: string;
|
|
||||||
location_name?: string;
|
|
||||||
staff_id?: string;
|
|
||||||
staff_name?: string;
|
|
||||||
customer_id?: string;
|
|
||||||
customer_email?: string;
|
|
||||||
customer_phone?: string;
|
|
||||||
order_type?: string;
|
|
||||||
table_number?: string;
|
|
||||||
receipt_number?: string;
|
|
||||||
is_synced_to_sales: boolean;
|
|
||||||
sales_record_id?: string;
|
|
||||||
sync_attempted_at?: string;
|
|
||||||
sync_completed_at?: string;
|
|
||||||
sync_error?: string;
|
|
||||||
sync_retry_count: number;
|
|
||||||
is_processed: boolean;
|
|
||||||
processing_error?: string;
|
|
||||||
is_duplicate: boolean;
|
|
||||||
duplicate_of?: string;
|
|
||||||
created_at: string;
|
|
||||||
updated_at: string;
|
|
||||||
items: POSTransactionItem[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface POSTransactionItem {
|
|
||||||
id: string;
|
|
||||||
transaction_id: string;
|
|
||||||
external_item_id?: string;
|
|
||||||
sku?: string;
|
|
||||||
product_name: string;
|
|
||||||
product_category?: string;
|
|
||||||
product_subcategory?: string;
|
|
||||||
quantity: number;
|
|
||||||
unit_price: number;
|
|
||||||
total_price: number;
|
|
||||||
discount_amount: number;
|
|
||||||
tax_amount: number;
|
|
||||||
modifiers?: Record<string, any>;
|
|
||||||
inventory_product_id?: string;
|
|
||||||
is_mapped_to_inventory: boolean;
|
|
||||||
is_synced_to_sales: boolean;
|
|
||||||
sync_error?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface POSSyncLog {
|
|
||||||
id: string;
|
|
||||||
tenant_id: string;
|
|
||||||
pos_config_id: string;
|
|
||||||
sync_type: 'full' | 'incremental' | 'manual' | 'webhook_triggered';
|
|
||||||
sync_direction: 'inbound' | 'outbound' | 'bidirectional';
|
|
||||||
data_type: 'transactions' | 'products' | 'customers' | 'orders';
|
|
||||||
pos_system: string;
|
|
||||||
status: 'started' | 'in_progress' | 'completed' | 'failed' | 'cancelled';
|
|
||||||
started_at: string;
|
|
||||||
completed_at?: string;
|
|
||||||
duration_seconds?: number;
|
|
||||||
sync_from_date?: string;
|
|
||||||
sync_to_date?: string;
|
|
||||||
records_requested: number;
|
|
||||||
records_processed: number;
|
|
||||||
records_created: number;
|
|
||||||
records_updated: number;
|
|
||||||
records_skipped: number;
|
|
||||||
records_failed: number;
|
|
||||||
api_calls_made: number;
|
|
||||||
error_message?: string;
|
|
||||||
error_code?: string;
|
|
||||||
retry_attempt: number;
|
|
||||||
max_retries: number;
|
|
||||||
progress_percentage?: number;
|
|
||||||
revenue_synced?: number;
|
|
||||||
transactions_synced: number;
|
|
||||||
triggered_by?: string;
|
|
||||||
triggered_by_user_id?: string;
|
|
||||||
created_at: string;
|
|
||||||
updated_at: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SyncRequest {
|
|
||||||
sync_type?: 'full' | 'incremental';
|
|
||||||
data_types?: ('transactions' | 'products' | 'customers')[];
|
|
||||||
from_date?: string;
|
|
||||||
to_date?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SyncStatus {
|
|
||||||
current_sync?: POSSyncLog;
|
|
||||||
last_successful_sync?: POSSyncLog;
|
|
||||||
recent_syncs: POSSyncLog[];
|
|
||||||
sync_health: {
|
|
||||||
status: 'healthy' | 'unhealthy' | 'warning';
|
|
||||||
success_rate: number;
|
|
||||||
average_duration_minutes: number;
|
|
||||||
last_error?: string;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SupportedPOSSystem {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
description: string;
|
|
||||||
features: string[];
|
|
||||||
supported_regions: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface POSAnalytics {
|
|
||||||
period_days: number;
|
|
||||||
total_syncs: number;
|
|
||||||
successful_syncs: number;
|
|
||||||
failed_syncs: number;
|
|
||||||
success_rate: number;
|
|
||||||
average_duration_minutes: number;
|
|
||||||
total_transactions_synced: number;
|
|
||||||
total_revenue_synced: number;
|
|
||||||
sync_frequency: {
|
|
||||||
daily_average: number;
|
|
||||||
peak_day?: string;
|
|
||||||
peak_count: number;
|
|
||||||
};
|
|
||||||
error_analysis: {
|
|
||||||
common_errors: Array<{ error: string; count: number }>;
|
|
||||||
error_trends: Array<{ date: string; count: number }>;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ConnectionTestResult {
|
|
||||||
status: 'success' | 'error';
|
|
||||||
message: string;
|
|
||||||
tested_at: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// API FUNCTIONS
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
export const posService = {
|
|
||||||
// Configuration Management
|
|
||||||
async getConfigurations(tenantId: string, params?: {
|
|
||||||
pos_system?: string;
|
|
||||||
is_active?: boolean;
|
|
||||||
limit?: number;
|
|
||||||
offset?: number;
|
|
||||||
}): Promise<{ configurations: POSConfiguration[]; total: number }> {
|
|
||||||
const response = await apiClient.get(`/pos-service/api/v1/tenants/${tenantId}/pos/configurations`, {
|
|
||||||
params
|
|
||||||
});
|
|
||||||
return response.data;
|
|
||||||
},
|
|
||||||
|
|
||||||
async createConfiguration(tenantId: string, data: CreatePOSConfigurationRequest): Promise<POSConfiguration> {
|
|
||||||
const response = await apiClient.post(`/pos-service/api/v1/tenants/${tenantId}/pos/configurations`, data);
|
|
||||||
return response.data;
|
|
||||||
},
|
|
||||||
|
|
||||||
async getConfiguration(tenantId: string, configId: string): Promise<POSConfiguration> {
|
|
||||||
const response = await apiClient.get(`/pos-service/api/v1/tenants/${tenantId}/pos/configurations/${configId}`);
|
|
||||||
return response.data;
|
|
||||||
},
|
|
||||||
|
|
||||||
async updateConfiguration(tenantId: string, configId: string, data: UpdatePOSConfigurationRequest): Promise<POSConfiguration> {
|
|
||||||
const response = await apiClient.put(`/pos-service/api/v1/tenants/${tenantId}/pos/configurations/${configId}`, data);
|
|
||||||
return response.data;
|
|
||||||
},
|
|
||||||
|
|
||||||
async deleteConfiguration(tenantId: string, configId: string): Promise<void> {
|
|
||||||
await apiClient.delete(`/pos-service/api/v1/tenants/${tenantId}/pos/configurations/${configId}`);
|
|
||||||
},
|
|
||||||
|
|
||||||
async testConnection(tenantId: string, configId: string): Promise<ConnectionTestResult> {
|
|
||||||
const response = await apiClient.post(`/pos-service/api/v1/tenants/${tenantId}/pos/configurations/${configId}/test-connection`);
|
|
||||||
return response.data;
|
|
||||||
},
|
|
||||||
|
|
||||||
// Synchronization
|
|
||||||
async triggerSync(tenantId: string, configId: string, syncRequest: SyncRequest): Promise<{
|
|
||||||
message: string;
|
|
||||||
sync_id: string;
|
|
||||||
status: string;
|
|
||||||
sync_type: string;
|
|
||||||
data_types: string[];
|
|
||||||
estimated_duration: string;
|
|
||||||
}> {
|
|
||||||
const response = await apiClient.post(`/pos-service/api/v1/tenants/${tenantId}/pos/configurations/${configId}/sync`, syncRequest);
|
|
||||||
return response.data;
|
|
||||||
},
|
|
||||||
|
|
||||||
async getSyncStatus(tenantId: string, configId: string, limit?: number): Promise<SyncStatus> {
|
|
||||||
const response = await apiClient.get(`/pos-service/api/v1/tenants/${tenantId}/pos/configurations/${configId}/sync/status`, {
|
|
||||||
params: { limit }
|
|
||||||
});
|
|
||||||
return response.data;
|
|
||||||
},
|
|
||||||
|
|
||||||
async getSyncLogs(tenantId: string, configId: string, params?: {
|
|
||||||
limit?: number;
|
|
||||||
offset?: number;
|
|
||||||
status?: string;
|
|
||||||
sync_type?: string;
|
|
||||||
data_type?: string;
|
|
||||||
}): Promise<{ logs: POSSyncLog[]; total: number; has_more: boolean }> {
|
|
||||||
const response = await apiClient.get(`/pos-service/api/v1/tenants/${tenantId}/pos/configurations/${configId}/sync/logs`, {
|
|
||||||
params
|
|
||||||
});
|
|
||||||
return response.data;
|
|
||||||
},
|
|
||||||
|
|
||||||
// Transaction Management
|
|
||||||
async getTransactions(tenantId: string, params?: {
|
|
||||||
pos_system?: string;
|
|
||||||
start_date?: string;
|
|
||||||
end_date?: string;
|
|
||||||
status?: string;
|
|
||||||
is_synced?: boolean;
|
|
||||||
limit?: number;
|
|
||||||
offset?: number;
|
|
||||||
}): Promise<{
|
|
||||||
transactions: POSTransaction[];
|
|
||||||
total: number;
|
|
||||||
has_more: boolean;
|
|
||||||
summary: {
|
|
||||||
total_amount: number;
|
|
||||||
transaction_count: number;
|
|
||||||
sync_status: {
|
|
||||||
synced: number;
|
|
||||||
pending: number;
|
|
||||||
failed: number;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
}> {
|
|
||||||
const response = await apiClient.get(`/pos-service/api/v1/tenants/${tenantId}/pos/transactions`, {
|
|
||||||
params
|
|
||||||
});
|
|
||||||
return response.data;
|
|
||||||
},
|
|
||||||
|
|
||||||
async syncSingleTransaction(tenantId: string, transactionId: string, force?: boolean): Promise<{
|
|
||||||
message: string;
|
|
||||||
transaction_id: string;
|
|
||||||
sync_status: string;
|
|
||||||
sales_record_id?: string;
|
|
||||||
}> {
|
|
||||||
const response = await apiClient.post(`/pos-service/api/v1/tenants/${tenantId}/pos/transactions/${transactionId}/sync`,
|
|
||||||
{}, { params: { force } }
|
|
||||||
);
|
|
||||||
return response.data;
|
|
||||||
},
|
|
||||||
|
|
||||||
async resyncFailedTransactions(tenantId: string, daysBack: number): Promise<{
|
|
||||||
message: string;
|
|
||||||
job_id: string;
|
|
||||||
scope: string;
|
|
||||||
estimated_transactions: number;
|
|
||||||
}> {
|
|
||||||
const response = await apiClient.post(`/pos-service/api/v1/tenants/${tenantId}/pos/data/resync`,
|
|
||||||
{}, { params: { days_back: daysBack } }
|
|
||||||
);
|
|
||||||
return response.data;
|
|
||||||
},
|
|
||||||
|
|
||||||
// Analytics
|
|
||||||
async getSyncAnalytics(tenantId: string, days: number = 30): Promise<POSAnalytics> {
|
|
||||||
const response = await apiClient.get(`/pos-service/api/v1/tenants/${tenantId}/pos/analytics/sync-performance`, {
|
|
||||||
params: { days }
|
|
||||||
});
|
|
||||||
return response.data;
|
|
||||||
},
|
|
||||||
|
|
||||||
// System Information
|
|
||||||
async getSupportedSystems(): Promise<{ systems: SupportedPOSSystem[] }> {
|
|
||||||
const response = await apiClient.get('/pos-service/api/v1/pos/supported-systems');
|
|
||||||
return response.data;
|
|
||||||
},
|
|
||||||
|
|
||||||
// Webhook Status
|
|
||||||
async getWebhookStatus(posSystem: string): Promise<{
|
|
||||||
pos_system: string;
|
|
||||||
status: string;
|
|
||||||
endpoint: string;
|
|
||||||
supported_events: {
|
|
||||||
events: string[];
|
|
||||||
format: string;
|
|
||||||
authentication: string;
|
|
||||||
};
|
|
||||||
last_received?: string;
|
|
||||||
total_received: number;
|
|
||||||
}> {
|
|
||||||
const response = await apiClient.get(`/pos-service/api/v1/webhooks/${posSystem}/status`);
|
|
||||||
return response.data;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export default posService;
|
|
||||||
@@ -1,135 +0,0 @@
|
|||||||
// ================================================================
|
|
||||||
// frontend/src/api/services/procurement.service.ts
|
|
||||||
// ================================================================
|
|
||||||
/**
|
|
||||||
* Procurement Service - API client for procurement planning endpoints
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { ApiClient } from '../client';
|
|
||||||
import type {
|
|
||||||
ProcurementPlan,
|
|
||||||
GeneratePlanRequest,
|
|
||||||
GeneratePlanResponse,
|
|
||||||
DashboardData,
|
|
||||||
ProcurementRequirement,
|
|
||||||
PaginatedProcurementPlans
|
|
||||||
} from '../types/procurement';
|
|
||||||
|
|
||||||
export class ProcurementService {
|
|
||||||
constructor(private client: ApiClient) {}
|
|
||||||
|
|
||||||
// ================================================================
|
|
||||||
// PROCUREMENT PLAN OPERATIONS
|
|
||||||
// ================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the procurement plan for the current day
|
|
||||||
*/
|
|
||||||
async getCurrentPlan(): Promise<ProcurementPlan | null> {
|
|
||||||
return this.client.get('/procurement-plans/current');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get procurement plan for a specific date
|
|
||||||
*/
|
|
||||||
async getPlanByDate(date: string): Promise<ProcurementPlan | null> {
|
|
||||||
return this.client.get(`/procurement-plans/${date}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get procurement plan by ID
|
|
||||||
*/
|
|
||||||
async getPlanById(planId: string): Promise<ProcurementPlan | null> {
|
|
||||||
return this.client.get(`/procurement-plans/id/${planId}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* List procurement plans with optional filters
|
|
||||||
*/
|
|
||||||
async listPlans(params?: {
|
|
||||||
status?: string;
|
|
||||||
startDate?: string;
|
|
||||||
endDate?: string;
|
|
||||||
limit?: number;
|
|
||||||
offset?: number;
|
|
||||||
}): Promise<PaginatedProcurementPlans> {
|
|
||||||
return this.client.get('/procurement-plans/', { params });
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate a new procurement plan
|
|
||||||
*/
|
|
||||||
async generatePlan(request: GeneratePlanRequest): Promise<GeneratePlanResponse> {
|
|
||||||
return this.client.post('/procurement-plans/generate', request);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update procurement plan status
|
|
||||||
*/
|
|
||||||
async updatePlanStatus(planId: string, status: string): Promise<ProcurementPlan> {
|
|
||||||
return this.client.put(`/procurement-plans/${planId}/status`, null, {
|
|
||||||
params: { status }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ================================================================
|
|
||||||
// REQUIREMENTS OPERATIONS
|
|
||||||
// ================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get all requirements for a specific procurement plan
|
|
||||||
*/
|
|
||||||
async getPlanRequirements(
|
|
||||||
planId: string,
|
|
||||||
params?: {
|
|
||||||
status?: string;
|
|
||||||
priority?: string;
|
|
||||||
}
|
|
||||||
): Promise<ProcurementRequirement[]> {
|
|
||||||
return this.client.get(`/procurement-plans/${planId}/requirements`, { params });
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get all critical priority requirements
|
|
||||||
*/
|
|
||||||
async getCriticalRequirements(): Promise<ProcurementRequirement[]> {
|
|
||||||
return this.client.get('/procurement-plans/requirements/critical');
|
|
||||||
}
|
|
||||||
|
|
||||||
// ================================================================
|
|
||||||
// DASHBOARD OPERATIONS
|
|
||||||
// ================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get procurement dashboard data
|
|
||||||
*/
|
|
||||||
async getDashboardData(): Promise<DashboardData | null> {
|
|
||||||
return this.client.get('/procurement-plans/dashboard/data');
|
|
||||||
}
|
|
||||||
|
|
||||||
// ================================================================
|
|
||||||
// UTILITY OPERATIONS
|
|
||||||
// ================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Manually trigger the daily scheduler
|
|
||||||
*/
|
|
||||||
async triggerDailyScheduler(): Promise<{ success: boolean; message: string; tenant_id: string }> {
|
|
||||||
return this.client.post('/procurement-plans/scheduler/trigger');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Health check for procurement service
|
|
||||||
*/
|
|
||||||
async healthCheck(): Promise<{
|
|
||||||
status: string;
|
|
||||||
service: string;
|
|
||||||
procurement_enabled: boolean;
|
|
||||||
timestamp: string;
|
|
||||||
}> {
|
|
||||||
return this.client.get('/procurement-plans/health');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Export singleton instance
|
|
||||||
export const procurementService = new ProcurementService(new ApiClient());
|
|
||||||
@@ -1,296 +0,0 @@
|
|||||||
// ================================================================
|
|
||||||
// frontend/src/api/services/production.service.ts
|
|
||||||
// ================================================================
|
|
||||||
/**
|
|
||||||
* Production Service - API client for Production Service endpoints
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { apiClient } from '../client';
|
|
||||||
|
|
||||||
// Production Types
|
|
||||||
export interface ProductionBatch {
|
|
||||||
id: string;
|
|
||||||
recipe_id: string;
|
|
||||||
recipe_name: string;
|
|
||||||
quantity: number;
|
|
||||||
unit: string;
|
|
||||||
status: 'scheduled' | 'in_progress' | 'completed' | 'delayed' | 'failed';
|
|
||||||
scheduled_start: string;
|
|
||||||
actual_start?: string;
|
|
||||||
expected_end: string;
|
|
||||||
actual_end?: string;
|
|
||||||
equipment_id: string;
|
|
||||||
equipment_name: string;
|
|
||||||
operator_id: string;
|
|
||||||
operator_name: string;
|
|
||||||
temperature?: number;
|
|
||||||
humidity?: number;
|
|
||||||
quality_score?: number;
|
|
||||||
notes?: string;
|
|
||||||
created_at: string;
|
|
||||||
updated_at: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ProductionPlan {
|
|
||||||
id: string;
|
|
||||||
date: string;
|
|
||||||
total_capacity: number;
|
|
||||||
allocated_capacity: number;
|
|
||||||
efficiency_target: number;
|
|
||||||
quality_target: number;
|
|
||||||
batches: ProductionBatch[];
|
|
||||||
status: 'draft' | 'approved' | 'in_progress' | 'completed';
|
|
||||||
created_at: string;
|
|
||||||
updated_at: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Equipment {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
type: string;
|
|
||||||
status: 'active' | 'idle' | 'maintenance' | 'error';
|
|
||||||
location: string;
|
|
||||||
capacity: number;
|
|
||||||
current_batch_id?: string;
|
|
||||||
temperature?: number;
|
|
||||||
utilization: number;
|
|
||||||
last_maintenance: string;
|
|
||||||
next_maintenance: string;
|
|
||||||
created_at: string;
|
|
||||||
updated_at: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ProductionDashboardData {
|
|
||||||
summary: {
|
|
||||||
active_batches: number;
|
|
||||||
equipment_in_use: number;
|
|
||||||
current_efficiency: number;
|
|
||||||
todays_production: number;
|
|
||||||
};
|
|
||||||
efficiency_trend: { date: string; efficiency: number }[];
|
|
||||||
quality_trend: { date: string; quality: number }[];
|
|
||||||
equipment_status: Equipment[];
|
|
||||||
active_batches: ProductionBatch[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BatchCreateRequest {
|
|
||||||
recipe_id: string;
|
|
||||||
quantity: number;
|
|
||||||
scheduled_start: string;
|
|
||||||
expected_end: string;
|
|
||||||
equipment_id: string;
|
|
||||||
operator_id: string;
|
|
||||||
notes?: string;
|
|
||||||
priority?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BatchUpdateRequest {
|
|
||||||
status?: 'scheduled' | 'in_progress' | 'completed' | 'delayed' | 'failed';
|
|
||||||
actual_start?: string;
|
|
||||||
actual_end?: string;
|
|
||||||
temperature?: number;
|
|
||||||
humidity?: number;
|
|
||||||
quality_score?: number;
|
|
||||||
notes?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PlanCreateRequest {
|
|
||||||
date: string;
|
|
||||||
batches: BatchCreateRequest[];
|
|
||||||
efficiency_target?: number;
|
|
||||||
quality_target?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class ProductionService {
|
|
||||||
private readonly basePath = '/production';
|
|
||||||
|
|
||||||
// Dashboard
|
|
||||||
async getDashboardData(params?: {
|
|
||||||
date_from?: string;
|
|
||||||
date_to?: string;
|
|
||||||
}): Promise<ProductionDashboardData> {
|
|
||||||
return apiClient.get(`${this.basePath}/dashboard`, { params });
|
|
||||||
}
|
|
||||||
|
|
||||||
async getDashboardMetrics(params?: {
|
|
||||||
date_from?: string;
|
|
||||||
date_to?: string;
|
|
||||||
granularity?: 'hour' | 'day' | 'week' | 'month';
|
|
||||||
}): Promise<{
|
|
||||||
dates: string[];
|
|
||||||
efficiency: number[];
|
|
||||||
quality: number[];
|
|
||||||
production_volume: number[];
|
|
||||||
equipment_utilization: number[];
|
|
||||||
}> {
|
|
||||||
return apiClient.get(`${this.basePath}/dashboard/metrics`, { params });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Batches
|
|
||||||
async getBatches(params?: {
|
|
||||||
status?: string;
|
|
||||||
equipment_id?: string;
|
|
||||||
date_from?: string;
|
|
||||||
date_to?: string;
|
|
||||||
limit?: number;
|
|
||||||
offset?: number;
|
|
||||||
}): Promise<ProductionBatch[]> {
|
|
||||||
return apiClient.get(`${this.basePath}/batches`, { params });
|
|
||||||
}
|
|
||||||
|
|
||||||
async getBatch(batchId: string): Promise<ProductionBatch> {
|
|
||||||
return apiClient.get(`${this.basePath}/batches/${batchId}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
async createBatch(batch: BatchCreateRequest): Promise<ProductionBatch> {
|
|
||||||
return apiClient.post(`${this.basePath}/batches`, batch);
|
|
||||||
}
|
|
||||||
|
|
||||||
async updateBatch(batchId: string, updates: BatchUpdateRequest): Promise<ProductionBatch> {
|
|
||||||
return apiClient.put(`${this.basePath}/batches/${batchId}`, updates);
|
|
||||||
}
|
|
||||||
|
|
||||||
async deleteBatch(batchId: string): Promise<void> {
|
|
||||||
return apiClient.delete(`${this.basePath}/batches/${batchId}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
async startBatch(batchId: string): Promise<ProductionBatch> {
|
|
||||||
return apiClient.post(`${this.basePath}/batches/${batchId}/start`);
|
|
||||||
}
|
|
||||||
|
|
||||||
async completeBatch(batchId: string, qualityScore?: number, notes?: string): Promise<ProductionBatch> {
|
|
||||||
return apiClient.post(`${this.basePath}/batches/${batchId}/complete`, {
|
|
||||||
quality_score: qualityScore,
|
|
||||||
notes
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async getBatchStatus(batchId: string): Promise<{
|
|
||||||
status: string;
|
|
||||||
progress: number;
|
|
||||||
current_phase: string;
|
|
||||||
temperature: number;
|
|
||||||
humidity: number;
|
|
||||||
estimated_completion: string;
|
|
||||||
}> {
|
|
||||||
return apiClient.get(`${this.basePath}/batches/${batchId}/status`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Production Plans
|
|
||||||
async getPlans(params?: {
|
|
||||||
date_from?: string;
|
|
||||||
date_to?: string;
|
|
||||||
status?: string;
|
|
||||||
limit?: number;
|
|
||||||
offset?: number;
|
|
||||||
}): Promise<ProductionPlan[]> {
|
|
||||||
return apiClient.get(`${this.basePath}/plans`, { params });
|
|
||||||
}
|
|
||||||
|
|
||||||
async getPlan(planId: string): Promise<ProductionPlan> {
|
|
||||||
return apiClient.get(`${this.basePath}/plans/${planId}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
async createPlan(plan: PlanCreateRequest): Promise<ProductionPlan> {
|
|
||||||
return apiClient.post(`${this.basePath}/plans`, plan);
|
|
||||||
}
|
|
||||||
|
|
||||||
async updatePlan(planId: string, updates: Partial<PlanCreateRequest>): Promise<ProductionPlan> {
|
|
||||||
return apiClient.put(`${this.basePath}/plans/${planId}`, updates);
|
|
||||||
}
|
|
||||||
|
|
||||||
async deletePlan(planId: string): Promise<void> {
|
|
||||||
return apiClient.delete(`${this.basePath}/plans/${planId}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
async approvePlan(planId: string): Promise<ProductionPlan> {
|
|
||||||
return apiClient.post(`${this.basePath}/plans/${planId}/approve`);
|
|
||||||
}
|
|
||||||
|
|
||||||
async optimizePlan(planId: string): Promise<ProductionPlan> {
|
|
||||||
return apiClient.post(`${this.basePath}/plans/${planId}/optimize`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Equipment
|
|
||||||
async getEquipment(params?: {
|
|
||||||
status?: string;
|
|
||||||
type?: string;
|
|
||||||
location?: string;
|
|
||||||
limit?: number;
|
|
||||||
offset?: number;
|
|
||||||
}): Promise<Equipment[]> {
|
|
||||||
return apiClient.get(`${this.basePath}/equipment`, { params });
|
|
||||||
}
|
|
||||||
|
|
||||||
async getEquipmentById(equipmentId: string): Promise<Equipment> {
|
|
||||||
return apiClient.get(`${this.basePath}/equipment/${equipmentId}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
async updateEquipment(equipmentId: string, updates: {
|
|
||||||
status?: 'active' | 'idle' | 'maintenance' | 'error';
|
|
||||||
temperature?: number;
|
|
||||||
notes?: string;
|
|
||||||
}): Promise<Equipment> {
|
|
||||||
return apiClient.put(`${this.basePath}/equipment/${equipmentId}`, updates);
|
|
||||||
}
|
|
||||||
|
|
||||||
async getEquipmentMetrics(equipmentId: string, params?: {
|
|
||||||
date_from?: string;
|
|
||||||
date_to?: string;
|
|
||||||
}): Promise<{
|
|
||||||
utilization: number[];
|
|
||||||
temperature: number[];
|
|
||||||
maintenance_events: any[];
|
|
||||||
performance_score: number;
|
|
||||||
}> {
|
|
||||||
return apiClient.get(`${this.basePath}/equipment/${equipmentId}/metrics`, { params });
|
|
||||||
}
|
|
||||||
|
|
||||||
async scheduleMaintenanceForEquipment(equipmentId: string, scheduledDate: string, notes?: string): Promise<void> {
|
|
||||||
return apiClient.post(`${this.basePath}/equipment/${equipmentId}/maintenance`, {
|
|
||||||
scheduled_date: scheduledDate,
|
|
||||||
notes
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Analytics
|
|
||||||
async getEfficiencyTrends(params?: {
|
|
||||||
date_from?: string;
|
|
||||||
date_to?: string;
|
|
||||||
equipment_id?: string;
|
|
||||||
}): Promise<{
|
|
||||||
dates: string[];
|
|
||||||
efficiency: number[];
|
|
||||||
quality: number[];
|
|
||||||
volume: number[];
|
|
||||||
}> {
|
|
||||||
return apiClient.get(`${this.basePath}/analytics/efficiency`, { params });
|
|
||||||
}
|
|
||||||
|
|
||||||
async getProductionForecast(params?: {
|
|
||||||
days?: number;
|
|
||||||
include_weather?: boolean;
|
|
||||||
}): Promise<{
|
|
||||||
dates: string[];
|
|
||||||
predicted_volume: number[];
|
|
||||||
confidence_intervals: number[][];
|
|
||||||
factors: string[];
|
|
||||||
}> {
|
|
||||||
return apiClient.get(`${this.basePath}/analytics/forecast`, { params });
|
|
||||||
}
|
|
||||||
|
|
||||||
async getQualityAnalysis(params?: {
|
|
||||||
date_from?: string;
|
|
||||||
date_to?: string;
|
|
||||||
recipe_id?: string;
|
|
||||||
}): Promise<{
|
|
||||||
average_quality: number;
|
|
||||||
quality_trend: number[];
|
|
||||||
quality_factors: { factor: string; impact: number }[];
|
|
||||||
recommendations: string[];
|
|
||||||
}> {
|
|
||||||
return apiClient.get(`${this.basePath}/analytics/quality`, { params });
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,551 +0,0 @@
|
|||||||
// frontend/src/api/services/recipes.service.ts
|
|
||||||
/**
|
|
||||||
* Recipe Service API Client
|
|
||||||
* Handles all recipe and production management API calls
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { apiClient } from '../client';
|
|
||||||
import type {
|
|
||||||
PaginatedResponse,
|
|
||||||
ApiResponse,
|
|
||||||
CreateResponse,
|
|
||||||
UpdateResponse
|
|
||||||
} from '../types';
|
|
||||||
|
|
||||||
// Recipe Types
|
|
||||||
export interface Recipe {
|
|
||||||
id: string;
|
|
||||||
tenant_id: string;
|
|
||||||
name: string;
|
|
||||||
recipe_code?: string;
|
|
||||||
version: string;
|
|
||||||
finished_product_id: string;
|
|
||||||
description?: string;
|
|
||||||
category?: string;
|
|
||||||
cuisine_type?: string;
|
|
||||||
difficulty_level: number;
|
|
||||||
yield_quantity: number;
|
|
||||||
yield_unit: string;
|
|
||||||
prep_time_minutes?: number;
|
|
||||||
cook_time_minutes?: number;
|
|
||||||
total_time_minutes?: number;
|
|
||||||
rest_time_minutes?: number;
|
|
||||||
estimated_cost_per_unit?: number;
|
|
||||||
last_calculated_cost?: number;
|
|
||||||
cost_calculation_date?: string;
|
|
||||||
target_margin_percentage?: number;
|
|
||||||
suggested_selling_price?: number;
|
|
||||||
instructions?: Record<string, any>;
|
|
||||||
preparation_notes?: string;
|
|
||||||
storage_instructions?: string;
|
|
||||||
quality_standards?: string;
|
|
||||||
serves_count?: number;
|
|
||||||
nutritional_info?: Record<string, any>;
|
|
||||||
allergen_info?: Record<string, any>;
|
|
||||||
dietary_tags?: Record<string, any>;
|
|
||||||
batch_size_multiplier: number;
|
|
||||||
minimum_batch_size?: number;
|
|
||||||
maximum_batch_size?: number;
|
|
||||||
optimal_production_temperature?: number;
|
|
||||||
optimal_humidity?: number;
|
|
||||||
quality_check_points?: Record<string, any>;
|
|
||||||
common_issues?: Record<string, any>;
|
|
||||||
status: 'draft' | 'active' | 'testing' | 'archived' | 'discontinued';
|
|
||||||
is_seasonal: boolean;
|
|
||||||
season_start_month?: number;
|
|
||||||
season_end_month?: number;
|
|
||||||
is_signature_item: boolean;
|
|
||||||
created_at: string;
|
|
||||||
updated_at: string;
|
|
||||||
created_by?: string;
|
|
||||||
updated_by?: string;
|
|
||||||
ingredients?: RecipeIngredient[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RecipeIngredient {
|
|
||||||
id: string;
|
|
||||||
tenant_id: string;
|
|
||||||
recipe_id: string;
|
|
||||||
ingredient_id: string;
|
|
||||||
quantity: number;
|
|
||||||
unit: string;
|
|
||||||
quantity_in_base_unit?: number;
|
|
||||||
alternative_quantity?: number;
|
|
||||||
alternative_unit?: string;
|
|
||||||
preparation_method?: string;
|
|
||||||
ingredient_notes?: string;
|
|
||||||
is_optional: boolean;
|
|
||||||
ingredient_order: number;
|
|
||||||
ingredient_group?: string;
|
|
||||||
substitution_options?: Record<string, any>;
|
|
||||||
substitution_ratio?: number;
|
|
||||||
unit_cost?: number;
|
|
||||||
total_cost?: number;
|
|
||||||
cost_updated_at?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CreateRecipeRequest {
|
|
||||||
name: string;
|
|
||||||
recipe_code?: string;
|
|
||||||
version?: string;
|
|
||||||
finished_product_id: string;
|
|
||||||
description?: string;
|
|
||||||
category?: string;
|
|
||||||
cuisine_type?: string;
|
|
||||||
difficulty_level?: number;
|
|
||||||
yield_quantity: number;
|
|
||||||
yield_unit: string;
|
|
||||||
prep_time_minutes?: number;
|
|
||||||
cook_time_minutes?: number;
|
|
||||||
total_time_minutes?: number;
|
|
||||||
rest_time_minutes?: number;
|
|
||||||
instructions?: Record<string, any>;
|
|
||||||
preparation_notes?: string;
|
|
||||||
storage_instructions?: string;
|
|
||||||
quality_standards?: string;
|
|
||||||
serves_count?: number;
|
|
||||||
nutritional_info?: Record<string, any>;
|
|
||||||
allergen_info?: Record<string, any>;
|
|
||||||
dietary_tags?: Record<string, any>;
|
|
||||||
batch_size_multiplier?: number;
|
|
||||||
minimum_batch_size?: number;
|
|
||||||
maximum_batch_size?: number;
|
|
||||||
optimal_production_temperature?: number;
|
|
||||||
optimal_humidity?: number;
|
|
||||||
quality_check_points?: Record<string, any>;
|
|
||||||
common_issues?: Record<string, any>;
|
|
||||||
is_seasonal?: boolean;
|
|
||||||
season_start_month?: number;
|
|
||||||
season_end_month?: number;
|
|
||||||
is_signature_item?: boolean;
|
|
||||||
target_margin_percentage?: number;
|
|
||||||
ingredients: CreateRecipeIngredientRequest[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CreateRecipeIngredientRequest {
|
|
||||||
ingredient_id: string;
|
|
||||||
quantity: number;
|
|
||||||
unit: string;
|
|
||||||
alternative_quantity?: number;
|
|
||||||
alternative_unit?: string;
|
|
||||||
preparation_method?: string;
|
|
||||||
ingredient_notes?: string;
|
|
||||||
is_optional?: boolean;
|
|
||||||
ingredient_order: number;
|
|
||||||
ingredient_group?: string;
|
|
||||||
substitution_options?: Record<string, any>;
|
|
||||||
substitution_ratio?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UpdateRecipeRequest {
|
|
||||||
name?: string;
|
|
||||||
recipe_code?: string;
|
|
||||||
version?: string;
|
|
||||||
description?: string;
|
|
||||||
category?: string;
|
|
||||||
cuisine_type?: string;
|
|
||||||
difficulty_level?: number;
|
|
||||||
yield_quantity?: number;
|
|
||||||
yield_unit?: string;
|
|
||||||
prep_time_minutes?: number;
|
|
||||||
cook_time_minutes?: number;
|
|
||||||
total_time_minutes?: number;
|
|
||||||
rest_time_minutes?: number;
|
|
||||||
instructions?: Record<string, any>;
|
|
||||||
preparation_notes?: string;
|
|
||||||
storage_instructions?: string;
|
|
||||||
quality_standards?: string;
|
|
||||||
serves_count?: number;
|
|
||||||
nutritional_info?: Record<string, any>;
|
|
||||||
allergen_info?: Record<string, any>;
|
|
||||||
dietary_tags?: Record<string, any>;
|
|
||||||
batch_size_multiplier?: number;
|
|
||||||
minimum_batch_size?: number;
|
|
||||||
maximum_batch_size?: number;
|
|
||||||
optimal_production_temperature?: number;
|
|
||||||
optimal_humidity?: number;
|
|
||||||
quality_check_points?: Record<string, any>;
|
|
||||||
common_issues?: Record<string, any>;
|
|
||||||
status?: 'draft' | 'active' | 'testing' | 'archived' | 'discontinued';
|
|
||||||
is_seasonal?: boolean;
|
|
||||||
season_start_month?: number;
|
|
||||||
season_end_month?: number;
|
|
||||||
is_signature_item?: boolean;
|
|
||||||
target_margin_percentage?: number;
|
|
||||||
ingredients?: CreateRecipeIngredientRequest[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RecipeSearchParams {
|
|
||||||
search_term?: string;
|
|
||||||
status?: string;
|
|
||||||
category?: string;
|
|
||||||
is_seasonal?: boolean;
|
|
||||||
is_signature?: boolean;
|
|
||||||
difficulty_level?: number;
|
|
||||||
limit?: number;
|
|
||||||
offset?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RecipeFeasibility {
|
|
||||||
recipe_id: string;
|
|
||||||
recipe_name: string;
|
|
||||||
batch_multiplier: number;
|
|
||||||
feasible: boolean;
|
|
||||||
missing_ingredients: Array<{
|
|
||||||
ingredient_id: string;
|
|
||||||
ingredient_name: string;
|
|
||||||
required_quantity: number;
|
|
||||||
unit: string;
|
|
||||||
}>;
|
|
||||||
insufficient_ingredients: Array<{
|
|
||||||
ingredient_id: string;
|
|
||||||
ingredient_name: string;
|
|
||||||
required_quantity: number;
|
|
||||||
available_quantity: number;
|
|
||||||
unit: string;
|
|
||||||
}>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RecipeStatistics {
|
|
||||||
total_recipes: number;
|
|
||||||
active_recipes: number;
|
|
||||||
signature_recipes: number;
|
|
||||||
seasonal_recipes: number;
|
|
||||||
category_breakdown: Array<{
|
|
||||||
category: string;
|
|
||||||
count: number;
|
|
||||||
}>;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Production Types
|
|
||||||
export interface ProductionBatch {
|
|
||||||
id: string;
|
|
||||||
tenant_id: string;
|
|
||||||
recipe_id: string;
|
|
||||||
batch_number: string;
|
|
||||||
production_date: string;
|
|
||||||
planned_start_time?: string;
|
|
||||||
actual_start_time?: string;
|
|
||||||
planned_end_time?: string;
|
|
||||||
actual_end_time?: string;
|
|
||||||
planned_quantity: number;
|
|
||||||
actual_quantity?: number;
|
|
||||||
yield_percentage?: number;
|
|
||||||
batch_size_multiplier: number;
|
|
||||||
status: 'planned' | 'in_progress' | 'completed' | 'failed' | 'cancelled';
|
|
||||||
priority: 'low' | 'normal' | 'high' | 'urgent';
|
|
||||||
assigned_staff?: string[];
|
|
||||||
production_notes?: string;
|
|
||||||
quality_score?: number;
|
|
||||||
quality_notes?: string;
|
|
||||||
defect_rate?: number;
|
|
||||||
rework_required: boolean;
|
|
||||||
planned_material_cost?: number;
|
|
||||||
actual_material_cost?: number;
|
|
||||||
labor_cost?: number;
|
|
||||||
overhead_cost?: number;
|
|
||||||
total_production_cost?: number;
|
|
||||||
cost_per_unit?: number;
|
|
||||||
production_temperature?: number;
|
|
||||||
production_humidity?: number;
|
|
||||||
oven_temperature?: number;
|
|
||||||
baking_time_minutes?: number;
|
|
||||||
waste_quantity: number;
|
|
||||||
waste_reason?: string;
|
|
||||||
efficiency_percentage?: number;
|
|
||||||
customer_order_reference?: string;
|
|
||||||
pre_order_quantity?: number;
|
|
||||||
shelf_quantity?: number;
|
|
||||||
created_at: string;
|
|
||||||
updated_at: string;
|
|
||||||
created_by?: string;
|
|
||||||
completed_by?: string;
|
|
||||||
ingredient_consumptions?: ProductionIngredientConsumption[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ProductionIngredientConsumption {
|
|
||||||
id: string;
|
|
||||||
tenant_id: string;
|
|
||||||
production_batch_id: string;
|
|
||||||
recipe_ingredient_id: string;
|
|
||||||
ingredient_id: string;
|
|
||||||
stock_id?: string;
|
|
||||||
planned_quantity: number;
|
|
||||||
actual_quantity: number;
|
|
||||||
unit: string;
|
|
||||||
variance_quantity?: number;
|
|
||||||
variance_percentage?: number;
|
|
||||||
unit_cost?: number;
|
|
||||||
total_cost?: number;
|
|
||||||
consumption_time: string;
|
|
||||||
consumption_notes?: string;
|
|
||||||
staff_member?: string;
|
|
||||||
ingredient_condition?: string;
|
|
||||||
quality_impact?: string;
|
|
||||||
substitution_used: boolean;
|
|
||||||
substitution_details?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CreateProductionBatchRequest {
|
|
||||||
recipe_id: string;
|
|
||||||
batch_number?: string;
|
|
||||||
production_date: string;
|
|
||||||
planned_start_time?: string;
|
|
||||||
planned_end_time?: string;
|
|
||||||
planned_quantity: number;
|
|
||||||
batch_size_multiplier?: number;
|
|
||||||
priority?: 'low' | 'normal' | 'high' | 'urgent';
|
|
||||||
assigned_staff?: string[];
|
|
||||||
production_notes?: string;
|
|
||||||
customer_order_reference?: string;
|
|
||||||
pre_order_quantity?: number;
|
|
||||||
shelf_quantity?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UpdateProductionBatchRequest {
|
|
||||||
batch_number?: string;
|
|
||||||
production_date?: string;
|
|
||||||
planned_start_time?: string;
|
|
||||||
actual_start_time?: string;
|
|
||||||
planned_end_time?: string;
|
|
||||||
actual_end_time?: string;
|
|
||||||
planned_quantity?: number;
|
|
||||||
actual_quantity?: number;
|
|
||||||
batch_size_multiplier?: number;
|
|
||||||
status?: 'planned' | 'in_progress' | 'completed' | 'failed' | 'cancelled';
|
|
||||||
priority?: 'low' | 'normal' | 'high' | 'urgent';
|
|
||||||
assigned_staff?: string[];
|
|
||||||
production_notes?: string;
|
|
||||||
quality_score?: number;
|
|
||||||
quality_notes?: string;
|
|
||||||
defect_rate?: number;
|
|
||||||
rework_required?: boolean;
|
|
||||||
labor_cost?: number;
|
|
||||||
overhead_cost?: number;
|
|
||||||
production_temperature?: number;
|
|
||||||
production_humidity?: number;
|
|
||||||
oven_temperature?: number;
|
|
||||||
baking_time_minutes?: number;
|
|
||||||
waste_quantity?: number;
|
|
||||||
waste_reason?: string;
|
|
||||||
customer_order_reference?: string;
|
|
||||||
pre_order_quantity?: number;
|
|
||||||
shelf_quantity?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ProductionBatchSearchParams {
|
|
||||||
search_term?: string;
|
|
||||||
status?: string;
|
|
||||||
priority?: string;
|
|
||||||
start_date?: string;
|
|
||||||
end_date?: string;
|
|
||||||
recipe_id?: string;
|
|
||||||
limit?: number;
|
|
||||||
offset?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ProductionStatistics {
|
|
||||||
total_batches: number;
|
|
||||||
completed_batches: number;
|
|
||||||
failed_batches: number;
|
|
||||||
success_rate: number;
|
|
||||||
average_yield_percentage: number;
|
|
||||||
average_quality_score: number;
|
|
||||||
total_production_cost: number;
|
|
||||||
status_breakdown: Array<{
|
|
||||||
status: string;
|
|
||||||
count: number;
|
|
||||||
}>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class RecipesService {
|
|
||||||
private baseUrl = '/api/recipes/v1';
|
|
||||||
|
|
||||||
// Recipe Management
|
|
||||||
async getRecipes(tenantId: string, params?: RecipeSearchParams): Promise<Recipe[]> {
|
|
||||||
const response = await apiClient.get<Recipe[]>(`${this.baseUrl}/recipes`, {
|
|
||||||
headers: { 'X-Tenant-ID': tenantId },
|
|
||||||
params
|
|
||||||
});
|
|
||||||
return response;
|
|
||||||
}
|
|
||||||
|
|
||||||
async getRecipe(tenantId: string, recipeId: string): Promise<Recipe> {
|
|
||||||
const response = await apiClient.get<Recipe>(`${this.baseUrl}/recipes/${recipeId}`, {
|
|
||||||
headers: { 'X-Tenant-ID': tenantId }
|
|
||||||
});
|
|
||||||
return response;
|
|
||||||
}
|
|
||||||
|
|
||||||
async createRecipe(tenantId: string, userId: string, data: CreateRecipeRequest): Promise<Recipe> {
|
|
||||||
const response = await apiClient.post<Recipe>(`${this.baseUrl}/recipes`, data, {
|
|
||||||
headers: {
|
|
||||||
'X-Tenant-ID': tenantId,
|
|
||||||
'X-User-ID': userId
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return response;
|
|
||||||
}
|
|
||||||
|
|
||||||
async updateRecipe(tenantId: string, userId: string, recipeId: string, data: UpdateRecipeRequest): Promise<Recipe> {
|
|
||||||
const response = await apiClient.put<Recipe>(`${this.baseUrl}/recipes/${recipeId}`, data, {
|
|
||||||
headers: {
|
|
||||||
'X-Tenant-ID': tenantId,
|
|
||||||
'X-User-ID': userId
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return response;
|
|
||||||
}
|
|
||||||
|
|
||||||
async deleteRecipe(tenantId: string, recipeId: string): Promise<void> {
|
|
||||||
await apiClient.delete(`${this.baseUrl}/recipes/${recipeId}`, {
|
|
||||||
headers: { 'X-Tenant-ID': tenantId }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async duplicateRecipe(tenantId: string, userId: string, recipeId: string, newName: string): Promise<Recipe> {
|
|
||||||
const response = await apiClient.post<Recipe>(`${this.baseUrl}/recipes/${recipeId}/duplicate`,
|
|
||||||
{ new_name: newName },
|
|
||||||
{
|
|
||||||
headers: {
|
|
||||||
'X-Tenant-ID': tenantId,
|
|
||||||
'X-User-ID': userId
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
return response;
|
|
||||||
}
|
|
||||||
|
|
||||||
async activateRecipe(tenantId: string, userId: string, recipeId: string): Promise<Recipe> {
|
|
||||||
const response = await apiClient.post<Recipe>(`${this.baseUrl}/recipes/${recipeId}/activate`, {}, {
|
|
||||||
headers: {
|
|
||||||
'X-Tenant-ID': tenantId,
|
|
||||||
'X-User-ID': userId
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return response;
|
|
||||||
}
|
|
||||||
|
|
||||||
async checkRecipeFeasibility(tenantId: string, recipeId: string, batchMultiplier: number = 1.0): Promise<RecipeFeasibility> {
|
|
||||||
const response = await apiClient.get<RecipeFeasibility>(`${this.baseUrl}/recipes/${recipeId}/feasibility`, {
|
|
||||||
headers: { 'X-Tenant-ID': tenantId },
|
|
||||||
params: { batch_multiplier: batchMultiplier }
|
|
||||||
});
|
|
||||||
return response;
|
|
||||||
}
|
|
||||||
|
|
||||||
async getRecipeStatistics(tenantId: string): Promise<RecipeStatistics> {
|
|
||||||
const response = await apiClient.get<RecipeStatistics>(`${this.baseUrl}/recipes/statistics/dashboard`, {
|
|
||||||
headers: { 'X-Tenant-ID': tenantId }
|
|
||||||
});
|
|
||||||
return response;
|
|
||||||
}
|
|
||||||
|
|
||||||
async getRecipeCategories(tenantId: string): Promise<string[]> {
|
|
||||||
const response = await apiClient.get<{ categories: string[] }>(`${this.baseUrl}/recipes/categories/list`, {
|
|
||||||
headers: { 'X-Tenant-ID': tenantId }
|
|
||||||
});
|
|
||||||
return response.categories;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Production Management
|
|
||||||
async getProductionBatches(tenantId: string, params?: ProductionBatchSearchParams): Promise<ProductionBatch[]> {
|
|
||||||
const response = await apiClient.get<ProductionBatch[]>(`${this.baseUrl}/production/batches`, {
|
|
||||||
headers: { 'X-Tenant-ID': tenantId },
|
|
||||||
params
|
|
||||||
});
|
|
||||||
return response;
|
|
||||||
}
|
|
||||||
|
|
||||||
async getProductionBatch(tenantId: string, batchId: string): Promise<ProductionBatch> {
|
|
||||||
const response = await apiClient.get<ProductionBatch>(`${this.baseUrl}/production/batches/${batchId}`, {
|
|
||||||
headers: { 'X-Tenant-ID': tenantId }
|
|
||||||
});
|
|
||||||
return response;
|
|
||||||
}
|
|
||||||
|
|
||||||
async createProductionBatch(tenantId: string, userId: string, data: CreateProductionBatchRequest): Promise<ProductionBatch> {
|
|
||||||
const response = await apiClient.post<ProductionBatch>(`${this.baseUrl}/production/batches`, data, {
|
|
||||||
headers: {
|
|
||||||
'X-Tenant-ID': tenantId,
|
|
||||||
'X-User-ID': userId
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return response;
|
|
||||||
}
|
|
||||||
|
|
||||||
async updateProductionBatch(tenantId: string, userId: string, batchId: string, data: UpdateProductionBatchRequest): Promise<ProductionBatch> {
|
|
||||||
const response = await apiClient.put<ProductionBatch>(`${this.baseUrl}/production/batches/${batchId}`, data, {
|
|
||||||
headers: {
|
|
||||||
'X-Tenant-ID': tenantId,
|
|
||||||
'X-User-ID': userId
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return response;
|
|
||||||
}
|
|
||||||
|
|
||||||
async deleteProductionBatch(tenantId: string, batchId: string): Promise<void> {
|
|
||||||
await apiClient.delete(`${this.baseUrl}/production/batches/${batchId}`, {
|
|
||||||
headers: { 'X-Tenant-ID': tenantId }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async getActiveProductionBatches(tenantId: string): Promise<ProductionBatch[]> {
|
|
||||||
const response = await apiClient.get<ProductionBatch[]>(`${this.baseUrl}/production/batches/active/list`, {
|
|
||||||
headers: { 'X-Tenant-ID': tenantId }
|
|
||||||
});
|
|
||||||
return response;
|
|
||||||
}
|
|
||||||
|
|
||||||
async startProductionBatch(tenantId: string, userId: string, batchId: string, data: {
|
|
||||||
staff_member?: string;
|
|
||||||
production_notes?: string;
|
|
||||||
ingredient_consumptions: Array<{
|
|
||||||
recipe_ingredient_id: string;
|
|
||||||
ingredient_id: string;
|
|
||||||
stock_id?: string;
|
|
||||||
planned_quantity: number;
|
|
||||||
actual_quantity: number;
|
|
||||||
unit: string;
|
|
||||||
consumption_notes?: string;
|
|
||||||
ingredient_condition?: string;
|
|
||||||
substitution_used?: boolean;
|
|
||||||
substitution_details?: string;
|
|
||||||
}>;
|
|
||||||
}): Promise<ProductionBatch> {
|
|
||||||
const response = await apiClient.post<ProductionBatch>(`${this.baseUrl}/production/batches/${batchId}/start`, data, {
|
|
||||||
headers: {
|
|
||||||
'X-Tenant-ID': tenantId,
|
|
||||||
'X-User-ID': userId
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return response;
|
|
||||||
}
|
|
||||||
|
|
||||||
async completeProductionBatch(tenantId: string, userId: string, batchId: string, data: {
|
|
||||||
actual_quantity: number;
|
|
||||||
quality_score?: number;
|
|
||||||
quality_notes?: string;
|
|
||||||
defect_rate?: number;
|
|
||||||
waste_quantity?: number;
|
|
||||||
waste_reason?: string;
|
|
||||||
production_notes?: string;
|
|
||||||
staff_member?: string;
|
|
||||||
}): Promise<ProductionBatch> {
|
|
||||||
const response = await apiClient.post<ProductionBatch>(`${this.baseUrl}/production/batches/${batchId}/complete`, data, {
|
|
||||||
headers: {
|
|
||||||
'X-Tenant-ID': tenantId,
|
|
||||||
'X-User-ID': userId
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return response;
|
|
||||||
}
|
|
||||||
|
|
||||||
async getProductionStatistics(tenantId: string, startDate?: string, endDate?: string): Promise<ProductionStatistics> {
|
|
||||||
const response = await apiClient.get<ProductionStatistics>(`${this.baseUrl}/production/statistics/dashboard`, {
|
|
||||||
headers: { 'X-Tenant-ID': tenantId },
|
|
||||||
params: { start_date: startDate, end_date: endDate }
|
|
||||||
});
|
|
||||||
return response;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,219 +0,0 @@
|
|||||||
// frontend/src/api/services/sales.service.ts
|
|
||||||
/**
|
|
||||||
* Sales Data Service
|
|
||||||
* Handles sales data operations for the sales microservice
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { apiClient } from '../client';
|
|
||||||
import { RequestTimeouts } from '../client/config';
|
|
||||||
import type {
|
|
||||||
SalesData,
|
|
||||||
SalesValidationResult,
|
|
||||||
SalesDataQuery,
|
|
||||||
SalesDataImport,
|
|
||||||
SalesImportResult,
|
|
||||||
DashboardStats,
|
|
||||||
PaginatedResponse,
|
|
||||||
ActivityItem,
|
|
||||||
} from '../types';
|
|
||||||
|
|
||||||
export class SalesService {
|
|
||||||
/**
|
|
||||||
* Upload Sales History File
|
|
||||||
*/
|
|
||||||
async uploadSalesHistory(
|
|
||||||
tenantId: string,
|
|
||||||
file: File,
|
|
||||||
additionalData?: Record<string, any>
|
|
||||||
): Promise<SalesImportResult> {
|
|
||||||
// Determine file format
|
|
||||||
const fileName = file.name.toLowerCase();
|
|
||||||
let fileFormat: string;
|
|
||||||
|
|
||||||
if (fileName.endsWith('.csv')) {
|
|
||||||
fileFormat = 'csv';
|
|
||||||
} else if (fileName.endsWith('.json')) {
|
|
||||||
fileFormat = 'json';
|
|
||||||
} else if (fileName.endsWith('.xlsx') || fileName.endsWith('.xls')) {
|
|
||||||
fileFormat = 'excel';
|
|
||||||
} else {
|
|
||||||
fileFormat = 'csv'; // Default fallback
|
|
||||||
}
|
|
||||||
|
|
||||||
const uploadData = {
|
|
||||||
file_format: fileFormat,
|
|
||||||
...additionalData,
|
|
||||||
};
|
|
||||||
|
|
||||||
return apiClient.upload(
|
|
||||||
`/tenants/${tenantId}/sales/import`,
|
|
||||||
file,
|
|
||||||
uploadData,
|
|
||||||
{
|
|
||||||
timeout: RequestTimeouts.LONG,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate Sales Data
|
|
||||||
*/
|
|
||||||
async validateSalesData(
|
|
||||||
tenantId: string,
|
|
||||||
file: File
|
|
||||||
): Promise<SalesValidationResult> {
|
|
||||||
const fileName = file.name.toLowerCase();
|
|
||||||
let fileFormat: string;
|
|
||||||
|
|
||||||
if (fileName.endsWith('.csv')) {
|
|
||||||
fileFormat = 'csv';
|
|
||||||
} else if (fileName.endsWith('.json')) {
|
|
||||||
fileFormat = 'json';
|
|
||||||
} else if (fileName.endsWith('.xlsx') || fileName.endsWith('.xls')) {
|
|
||||||
fileFormat = 'excel';
|
|
||||||
} else {
|
|
||||||
fileFormat = 'csv';
|
|
||||||
}
|
|
||||||
|
|
||||||
return apiClient.upload(
|
|
||||||
`/tenants/${tenantId}/sales/import/validate`,
|
|
||||||
file,
|
|
||||||
{
|
|
||||||
file_format: fileFormat,
|
|
||||||
validate_only: true,
|
|
||||||
source: 'onboarding_upload',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
timeout: RequestTimeouts.MEDIUM,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get Sales Data
|
|
||||||
*/
|
|
||||||
async getSalesData(
|
|
||||||
tenantId: string,
|
|
||||||
query?: SalesDataQuery
|
|
||||||
): Promise<PaginatedResponse<SalesData>> {
|
|
||||||
return apiClient.get(`/tenants/${tenantId}/sales`, { params: query });
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get Single Sales Record
|
|
||||||
*/
|
|
||||||
async getSalesRecord(tenantId: string, recordId: string): Promise<SalesData> {
|
|
||||||
return apiClient.get(`/tenants/${tenantId}/sales/${recordId}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update Sales Record
|
|
||||||
*/
|
|
||||||
async updateSalesRecord(
|
|
||||||
tenantId: string,
|
|
||||||
recordId: string,
|
|
||||||
data: Partial<SalesData>
|
|
||||||
): Promise<SalesData> {
|
|
||||||
return apiClient.put(`/tenants/${tenantId}/sales/${recordId}`, data);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete Sales Record
|
|
||||||
*/
|
|
||||||
async deleteSalesRecord(tenantId: string, recordId: string): Promise<{ message: string }> {
|
|
||||||
return apiClient.delete(`/tenants/${tenantId}/sales/${recordId}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get Dashboard Statistics
|
|
||||||
*/
|
|
||||||
async getDashboardStats(tenantId: string): Promise<DashboardStats> {
|
|
||||||
return apiClient.get(`/tenants/${tenantId}/sales/stats`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get Analytics Data
|
|
||||||
*/
|
|
||||||
async getAnalytics(
|
|
||||||
tenantId: string,
|
|
||||||
params?: {
|
|
||||||
start_date?: string;
|
|
||||||
end_date?: string;
|
|
||||||
inventory_product_ids?: string[]; // Primary way to filter by products
|
|
||||||
product_names?: string[]; // For backward compatibility - will need inventory service lookup
|
|
||||||
metrics?: string[];
|
|
||||||
}
|
|
||||||
): Promise<any> {
|
|
||||||
return apiClient.get(`/tenants/${tenantId}/sales/analytics`, { params });
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Export Sales Data
|
|
||||||
*/
|
|
||||||
async exportSalesData(
|
|
||||||
tenantId: string,
|
|
||||||
format: 'csv' | 'excel' | 'json',
|
|
||||||
query?: SalesDataQuery
|
|
||||||
): Promise<Blob> {
|
|
||||||
const response = await apiClient.request(`/tenants/${tenantId}/sales/export`, {
|
|
||||||
method: 'GET',
|
|
||||||
params: { ...query, format },
|
|
||||||
headers: {
|
|
||||||
'Accept': format === 'csv' ? 'text/csv' :
|
|
||||||
format === 'excel' ? 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' :
|
|
||||||
'application/json',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return new Blob([response], {
|
|
||||||
type: format === 'csv' ? 'text/csv' :
|
|
||||||
format === 'excel' ? 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' :
|
|
||||||
'application/json',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get Recent Activity
|
|
||||||
*/
|
|
||||||
async getRecentActivity(tenantId: string, limit?: number): Promise<ActivityItem[]> {
|
|
||||||
return apiClient.get(`/tenants/${tenantId}/sales/activity`, {
|
|
||||||
params: { limit },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get Sales Summary by Period
|
|
||||||
*/
|
|
||||||
async getSalesSummary(
|
|
||||||
tenantId: string,
|
|
||||||
period: 'daily' | 'weekly' | 'monthly' = 'daily'
|
|
||||||
): Promise<any> {
|
|
||||||
return apiClient.get(`/tenants/${tenantId}/sales/summary`, {
|
|
||||||
params: { period }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get Sales Analytics
|
|
||||||
*/
|
|
||||||
async getSalesAnalytics(
|
|
||||||
tenantId: string,
|
|
||||||
startDate?: string,
|
|
||||||
endDate?: string
|
|
||||||
): Promise<{
|
|
||||||
total_revenue: number;
|
|
||||||
waste_reduction_percentage?: number;
|
|
||||||
forecast_accuracy?: number;
|
|
||||||
stockout_events?: number;
|
|
||||||
}> {
|
|
||||||
return apiClient.get(`/tenants/${tenantId}/sales/analytics/summary`, {
|
|
||||||
params: {
|
|
||||||
start_date: startDate,
|
|
||||||
end_date: endDate
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const salesService = new SalesService();
|
|
||||||
@@ -1,475 +0,0 @@
|
|||||||
// ================================================================
|
|
||||||
// frontend/src/api/services/suppliers.service.ts
|
|
||||||
// ================================================================
|
|
||||||
/**
|
|
||||||
* Suppliers Service - API client for Suppliers Service endpoints
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { apiClient } from '../client';
|
|
||||||
|
|
||||||
// Supplier Types
|
|
||||||
export interface Supplier {
|
|
||||||
id: string;
|
|
||||||
tenant_id: string;
|
|
||||||
name: string;
|
|
||||||
contact_person?: string;
|
|
||||||
email?: string;
|
|
||||||
phone?: string;
|
|
||||||
address?: string;
|
|
||||||
supplier_type: 'ingredients' | 'packaging' | 'equipment' | 'services';
|
|
||||||
status: 'active' | 'inactive' | 'pending_approval';
|
|
||||||
payment_terms?: string;
|
|
||||||
lead_time_days: number;
|
|
||||||
minimum_order_value?: number;
|
|
||||||
delivery_areas: string[];
|
|
||||||
certifications: string[];
|
|
||||||
rating?: number;
|
|
||||||
notes?: string;
|
|
||||||
created_at: string;
|
|
||||||
updated_at: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SupplierPerformance {
|
|
||||||
supplier_id: string;
|
|
||||||
supplier_name: string;
|
|
||||||
period_start: string;
|
|
||||||
period_end: string;
|
|
||||||
metrics: {
|
|
||||||
delivery_performance: {
|
|
||||||
on_time_delivery_rate: number;
|
|
||||||
average_delay_days: number;
|
|
||||||
total_deliveries: number;
|
|
||||||
};
|
|
||||||
quality_performance: {
|
|
||||||
quality_score: number;
|
|
||||||
defect_rate: number;
|
|
||||||
complaints_count: number;
|
|
||||||
returns_count: number;
|
|
||||||
};
|
|
||||||
cost_performance: {
|
|
||||||
price_competitiveness: number;
|
|
||||||
cost_savings: number;
|
|
||||||
invoice_accuracy: number;
|
|
||||||
};
|
|
||||||
service_performance: {
|
|
||||||
responsiveness_score: number;
|
|
||||||
communication_score: number;
|
|
||||||
flexibility_score: number;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
overall_score: number;
|
|
||||||
performance_trend: 'improving' | 'stable' | 'declining';
|
|
||||||
risk_level: 'low' | 'medium' | 'high' | 'critical';
|
|
||||||
recommendations: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Additional types for hooks
|
|
||||||
export interface SupplierSummary {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
supplier_type: string;
|
|
||||||
status: string;
|
|
||||||
rating?: number;
|
|
||||||
total_orders: number;
|
|
||||||
total_spent: number;
|
|
||||||
last_delivery_date?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CreateSupplierRequest {
|
|
||||||
name: string;
|
|
||||||
contact_person?: string;
|
|
||||||
email?: string;
|
|
||||||
phone?: string;
|
|
||||||
address?: string;
|
|
||||||
supplier_type: 'ingredients' | 'packaging' | 'equipment' | 'services';
|
|
||||||
payment_terms?: string;
|
|
||||||
lead_time_days: number;
|
|
||||||
minimum_order_value?: number;
|
|
||||||
delivery_areas: string[];
|
|
||||||
certifications?: string[];
|
|
||||||
notes?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UpdateSupplierRequest extends Partial<CreateSupplierRequest> {}
|
|
||||||
|
|
||||||
export interface SupplierSearchParams {
|
|
||||||
supplier_type?: string;
|
|
||||||
status?: string;
|
|
||||||
search?: string;
|
|
||||||
delivery_area?: string;
|
|
||||||
limit?: number;
|
|
||||||
offset?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SupplierStatistics {
|
|
||||||
total_suppliers: number;
|
|
||||||
active_suppliers: number;
|
|
||||||
average_rating: number;
|
|
||||||
top_performing_suppliers: SupplierSummary[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PurchaseOrder {
|
|
||||||
id: string;
|
|
||||||
supplier_id: string;
|
|
||||||
supplier_name: string;
|
|
||||||
order_number: string;
|
|
||||||
status: 'pending' | 'confirmed' | 'shipped' | 'delivered' | 'cancelled';
|
|
||||||
total_amount: number;
|
|
||||||
created_at: string;
|
|
||||||
expected_delivery: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CreatePurchaseOrderRequest {
|
|
||||||
supplier_id: string;
|
|
||||||
items: Array<{
|
|
||||||
product_id: string;
|
|
||||||
quantity: number;
|
|
||||||
unit_price: number;
|
|
||||||
}>;
|
|
||||||
delivery_date: string;
|
|
||||||
notes?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PurchaseOrderSearchParams {
|
|
||||||
supplier_id?: string;
|
|
||||||
status?: string;
|
|
||||||
date_from?: string;
|
|
||||||
date_to?: string;
|
|
||||||
limit?: number;
|
|
||||||
offset?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PurchaseOrderStatistics {
|
|
||||||
total_orders: number;
|
|
||||||
total_value: number;
|
|
||||||
pending_orders: number;
|
|
||||||
overdue_orders: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Delivery {
|
|
||||||
id: string;
|
|
||||||
purchase_order_id: string;
|
|
||||||
supplier_name: string;
|
|
||||||
delivered_at: string;
|
|
||||||
status: 'on_time' | 'late' | 'early';
|
|
||||||
quality_rating?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DeliverySearchParams {
|
|
||||||
supplier_id?: string;
|
|
||||||
status?: string;
|
|
||||||
date_from?: string;
|
|
||||||
date_to?: string;
|
|
||||||
limit?: number;
|
|
||||||
offset?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DeliveryPerformanceStats {
|
|
||||||
on_time_delivery_rate: number;
|
|
||||||
average_delay_days: number;
|
|
||||||
quality_average: number;
|
|
||||||
total_deliveries: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SupplierDashboardData {
|
|
||||||
summary: {
|
|
||||||
total_suppliers: number;
|
|
||||||
active_suppliers: number;
|
|
||||||
pending_orders: number;
|
|
||||||
overdue_deliveries: number;
|
|
||||||
average_performance_score: number;
|
|
||||||
total_monthly_spend: number;
|
|
||||||
};
|
|
||||||
top_performers: SupplierPerformance[];
|
|
||||||
recent_orders: any[];
|
|
||||||
performance_trends: {
|
|
||||||
dates: string[];
|
|
||||||
delivery_rates: number[];
|
|
||||||
quality_scores: number[];
|
|
||||||
cost_savings: number[];
|
|
||||||
};
|
|
||||||
contract_expirations: { supplier_name: string; days_until_expiry: number }[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export class SuppliersService {
|
|
||||||
private readonly basePath = '/suppliers';
|
|
||||||
|
|
||||||
// Dashboard
|
|
||||||
async getDashboardData(params?: {
|
|
||||||
date_from?: string;
|
|
||||||
date_to?: string;
|
|
||||||
}): Promise<SupplierDashboardData> {
|
|
||||||
return apiClient.get(`${this.basePath}/dashboard`, { params });
|
|
||||||
}
|
|
||||||
|
|
||||||
async getDashboardMetrics(params?: {
|
|
||||||
date_from?: string;
|
|
||||||
date_to?: string;
|
|
||||||
supplier_id?: string;
|
|
||||||
}): Promise<{
|
|
||||||
dates: string[];
|
|
||||||
delivery_performance: number[];
|
|
||||||
quality_scores: number[];
|
|
||||||
cost_savings: number[];
|
|
||||||
order_volumes: number[];
|
|
||||||
}> {
|
|
||||||
return apiClient.get(`${this.basePath}/dashboard/metrics`, { params });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Suppliers
|
|
||||||
async getSuppliers(params?: {
|
|
||||||
supplier_type?: string;
|
|
||||||
status?: string;
|
|
||||||
search?: string;
|
|
||||||
delivery_area?: string;
|
|
||||||
limit?: number;
|
|
||||||
offset?: number;
|
|
||||||
}): Promise<Supplier[]> {
|
|
||||||
return apiClient.get(`${this.basePath}`, { params });
|
|
||||||
}
|
|
||||||
|
|
||||||
async getSupplier(supplierId: string): Promise<Supplier> {
|
|
||||||
return apiClient.get(`${this.basePath}/${supplierId}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
async createSupplier(supplier: {
|
|
||||||
name: string;
|
|
||||||
contact_person?: string;
|
|
||||||
email?: string;
|
|
||||||
phone?: string;
|
|
||||||
address?: string;
|
|
||||||
supplier_type: 'ingredients' | 'packaging' | 'equipment' | 'services';
|
|
||||||
payment_terms?: string;
|
|
||||||
lead_time_days: number;
|
|
||||||
minimum_order_value?: number;
|
|
||||||
delivery_areas: string[];
|
|
||||||
certifications?: string[];
|
|
||||||
notes?: string;
|
|
||||||
}): Promise<Supplier> {
|
|
||||||
return apiClient.post(`${this.basePath}`, supplier);
|
|
||||||
}
|
|
||||||
|
|
||||||
async updateSupplier(supplierId: string, updates: Partial<Supplier>): Promise<Supplier> {
|
|
||||||
return apiClient.put(`${this.basePath}/${supplierId}`, updates);
|
|
||||||
}
|
|
||||||
|
|
||||||
async deleteSupplier(supplierId: string): Promise<void> {
|
|
||||||
return apiClient.delete(`${this.basePath}/${supplierId}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Performance Management
|
|
||||||
async getSupplierPerformance(supplierId: string, params?: {
|
|
||||||
period_start?: string;
|
|
||||||
period_end?: string;
|
|
||||||
}): Promise<SupplierPerformance> {
|
|
||||||
return apiClient.get(`${this.basePath}/${supplierId}/performance`, { params });
|
|
||||||
}
|
|
||||||
|
|
||||||
async getAllSupplierPerformance(params?: {
|
|
||||||
period_start?: string;
|
|
||||||
period_end?: string;
|
|
||||||
min_score?: number;
|
|
||||||
risk_level?: string;
|
|
||||||
limit?: number;
|
|
||||||
offset?: number;
|
|
||||||
}): Promise<SupplierPerformance[]> {
|
|
||||||
return apiClient.get(`${this.basePath}/performance`, { params });
|
|
||||||
}
|
|
||||||
|
|
||||||
async updateSupplierRating(supplierId: string, rating: {
|
|
||||||
overall_rating: number;
|
|
||||||
delivery_rating: number;
|
|
||||||
quality_rating: number;
|
|
||||||
service_rating: number;
|
|
||||||
comments?: string;
|
|
||||||
}): Promise<void> {
|
|
||||||
return apiClient.post(`${this.basePath}/${supplierId}/rating`, rating);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Analytics
|
|
||||||
async getCostAnalysis(params?: {
|
|
||||||
date_from?: string;
|
|
||||||
date_to?: string;
|
|
||||||
supplier_id?: string;
|
|
||||||
category?: string;
|
|
||||||
}): Promise<{
|
|
||||||
total_spend: number;
|
|
||||||
cost_by_supplier: { supplier_name: string; amount: number }[];
|
|
||||||
cost_by_category: { category: string; amount: number }[];
|
|
||||||
cost_trends: { date: string; amount: number }[];
|
|
||||||
cost_savings_opportunities: string[];
|
|
||||||
}> {
|
|
||||||
return apiClient.get(`${this.basePath}/analytics/costs`, { params });
|
|
||||||
}
|
|
||||||
|
|
||||||
async getSupplyChainRiskAnalysis(): Promise<{
|
|
||||||
high_risk_suppliers: {
|
|
||||||
supplier_id: string;
|
|
||||||
supplier_name: string;
|
|
||||||
risk_factors: string[];
|
|
||||||
risk_score: number;
|
|
||||||
}[];
|
|
||||||
diversification_analysis: {
|
|
||||||
category: string;
|
|
||||||
supplier_count: number;
|
|
||||||
concentration_risk: number;
|
|
||||||
}[];
|
|
||||||
recommendations: string[];
|
|
||||||
}> {
|
|
||||||
return apiClient.get(`${this.basePath}/analytics/risk-analysis`);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// Additional methods for hooks compatibility
|
|
||||||
async getSupplierStatistics(): Promise<SupplierStatistics> {
|
|
||||||
const suppliers = await this.getSuppliers();
|
|
||||||
const activeSuppliers = suppliers.filter(s => s.status === 'active');
|
|
||||||
const averageRating = suppliers.reduce((sum, s) => sum + (s.rating || 0), 0) / suppliers.length;
|
|
||||||
|
|
||||||
return {
|
|
||||||
total_suppliers: suppliers.length,
|
|
||||||
active_suppliers: activeSuppliers.length,
|
|
||||||
average_rating: averageRating,
|
|
||||||
top_performing_suppliers: suppliers.slice(0, 5).map(s => ({
|
|
||||||
id: s.id,
|
|
||||||
name: s.name,
|
|
||||||
supplier_type: s.supplier_type,
|
|
||||||
status: s.status,
|
|
||||||
rating: s.rating,
|
|
||||||
total_orders: 0, // Would come from backend
|
|
||||||
total_spent: 0, // Would come from backend
|
|
||||||
last_delivery_date: undefined
|
|
||||||
}))
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async getActiveSuppliers(): Promise<SupplierSummary[]> {
|
|
||||||
const suppliers = await this.getSuppliers({ status: 'active' });
|
|
||||||
return suppliers.map(s => ({
|
|
||||||
id: s.id,
|
|
||||||
name: s.name,
|
|
||||||
supplier_type: s.supplier_type,
|
|
||||||
status: s.status,
|
|
||||||
rating: s.rating,
|
|
||||||
total_orders: 0, // Would come from backend
|
|
||||||
total_spent: 0, // Would come from backend
|
|
||||||
last_delivery_date: undefined
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
async getTopSuppliers(): Promise<SupplierSummary[]> {
|
|
||||||
const suppliers = await this.getSuppliers();
|
|
||||||
return suppliers
|
|
||||||
.sort((a, b) => (b.rating || 0) - (a.rating || 0))
|
|
||||||
.slice(0, 10)
|
|
||||||
.map(s => ({
|
|
||||||
id: s.id,
|
|
||||||
name: s.name,
|
|
||||||
supplier_type: s.supplier_type,
|
|
||||||
status: s.status,
|
|
||||||
rating: s.rating,
|
|
||||||
total_orders: 0, // Would come from backend
|
|
||||||
total_spent: 0, // Would come from backend
|
|
||||||
last_delivery_date: undefined
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
async getSuppliersNeedingReview(): Promise<SupplierSummary[]> {
|
|
||||||
const suppliers = await this.getSuppliers();
|
|
||||||
return suppliers
|
|
||||||
.filter(s => !s.rating || s.rating < 3)
|
|
||||||
.map(s => ({
|
|
||||||
id: s.id,
|
|
||||||
name: s.name,
|
|
||||||
supplier_type: s.supplier_type,
|
|
||||||
status: s.status,
|
|
||||||
rating: s.rating,
|
|
||||||
total_orders: 0, // Would come from backend
|
|
||||||
total_spent: 0, // Would come from backend
|
|
||||||
last_delivery_date: undefined
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Purchase Order Management Methods
|
|
||||||
async getPurchaseOrders(params?: PurchaseOrderSearchParams): Promise<PurchaseOrder[]> {
|
|
||||||
return apiClient.get(`${this.basePath}/purchase-orders`, { params });
|
|
||||||
}
|
|
||||||
|
|
||||||
async getPurchaseOrder(orderId: string): Promise<PurchaseOrder> {
|
|
||||||
return apiClient.get(`${this.basePath}/purchase-orders/${orderId}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
async createPurchaseOrder(orderData: CreatePurchaseOrderRequest): Promise<PurchaseOrder> {
|
|
||||||
return apiClient.post(`${this.basePath}/purchase-orders`, orderData);
|
|
||||||
}
|
|
||||||
|
|
||||||
async updatePurchaseOrderStatus(orderId: string, status: string): Promise<PurchaseOrder> {
|
|
||||||
return apiClient.put(`${this.basePath}/purchase-orders/${orderId}/status`, { status });
|
|
||||||
}
|
|
||||||
|
|
||||||
async approvePurchaseOrder(orderId: string, approval: any): Promise<void> {
|
|
||||||
return apiClient.post(`${this.basePath}/purchase-orders/${orderId}/approve`, approval);
|
|
||||||
}
|
|
||||||
|
|
||||||
async sendToSupplier(orderId: string): Promise<void> {
|
|
||||||
return apiClient.post(`${this.basePath}/purchase-orders/${orderId}/send`);
|
|
||||||
}
|
|
||||||
|
|
||||||
async cancelPurchaseOrder(orderId: string, reason?: string): Promise<void> {
|
|
||||||
return apiClient.post(`${this.basePath}/purchase-orders/${orderId}/cancel`, { reason });
|
|
||||||
}
|
|
||||||
|
|
||||||
async getPurchaseOrderStatistics(): Promise<PurchaseOrderStatistics> {
|
|
||||||
const orders = await this.getPurchaseOrders();
|
|
||||||
return {
|
|
||||||
total_orders: orders.length,
|
|
||||||
total_value: orders.reduce((sum, o) => sum + o.total_amount, 0),
|
|
||||||
pending_orders: orders.filter(o => o.status === 'pending').length,
|
|
||||||
overdue_orders: 0, // Would calculate based on expected delivery dates
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async getOrdersRequiringApproval(): Promise<PurchaseOrder[]> {
|
|
||||||
return this.getPurchaseOrders({ status: 'pending' });
|
|
||||||
}
|
|
||||||
|
|
||||||
async getOverdueOrders(): Promise<PurchaseOrder[]> {
|
|
||||||
const today = new Date();
|
|
||||||
const orders = await this.getPurchaseOrders();
|
|
||||||
return orders.filter(o => new Date(o.expected_delivery) < today && o.status !== 'delivered');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delivery Management Methods
|
|
||||||
async getDeliveries(params?: DeliverySearchParams): Promise<Delivery[]> {
|
|
||||||
return apiClient.get(`${this.basePath}/deliveries`, { params });
|
|
||||||
}
|
|
||||||
|
|
||||||
async getDelivery(deliveryId: string): Promise<Delivery> {
|
|
||||||
return apiClient.get(`${this.basePath}/deliveries/${deliveryId}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
async getTodaysDeliveries(): Promise<Delivery[]> {
|
|
||||||
const today = new Date().toISOString().split('T')[0];
|
|
||||||
return this.getDeliveries({ date_from: today, date_to: today });
|
|
||||||
}
|
|
||||||
|
|
||||||
async getDeliveryPerformanceStats(): Promise<DeliveryPerformanceStats> {
|
|
||||||
const deliveries = await this.getDeliveries();
|
|
||||||
const onTimeCount = deliveries.filter(d => d.status === 'on_time').length;
|
|
||||||
const totalCount = deliveries.length;
|
|
||||||
const qualitySum = deliveries.reduce((sum, d) => sum + (d.quality_rating || 0), 0);
|
|
||||||
|
|
||||||
return {
|
|
||||||
on_time_delivery_rate: totalCount > 0 ? (onTimeCount / totalCount) * 100 : 0,
|
|
||||||
average_delay_days: 0, // Would calculate based on actual vs expected delivery
|
|
||||||
quality_average: totalCount > 0 ? qualitySum / totalCount : 0,
|
|
||||||
total_deliveries: totalCount,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Additional utility methods
|
|
||||||
async approveSupplier(supplierId: string): Promise<void> {
|
|
||||||
await this.updateSupplier(supplierId, { status: 'active' });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,150 +0,0 @@
|
|||||||
// frontend/src/api/services/tenant.service.ts
|
|
||||||
/**
|
|
||||||
* Tenant Management Service
|
|
||||||
* Handles all tenant-related operations
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { apiClient } from '../client';
|
|
||||||
import { serviceEndpoints } from '../client/config';
|
|
||||||
import type {
|
|
||||||
TenantInfo,
|
|
||||||
TenantCreate,
|
|
||||||
TenantUpdate,
|
|
||||||
TenantMember,
|
|
||||||
InviteUser,
|
|
||||||
TenantStats,
|
|
||||||
PaginatedResponse,
|
|
||||||
BaseQueryParams,
|
|
||||||
} from '../types';
|
|
||||||
|
|
||||||
export class TenantService {
|
|
||||||
private baseEndpoint = serviceEndpoints.tenant;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create New Tenant
|
|
||||||
*/
|
|
||||||
async createTenant(data: TenantCreate): Promise<TenantInfo> {
|
|
||||||
return apiClient.post(`${this.baseEndpoint}/register`, data);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get Tenant Details
|
|
||||||
*/
|
|
||||||
async getTenant(tenantId: string): Promise<TenantInfo> {
|
|
||||||
return apiClient.get(`${this.baseEndpoint}/${tenantId}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update Tenant
|
|
||||||
*/
|
|
||||||
async updateTenant(tenantId: string, data: TenantUpdate): Promise<TenantInfo> {
|
|
||||||
return apiClient.put(`${this.baseEndpoint}/${tenantId}`, data);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete Tenant
|
|
||||||
*/
|
|
||||||
async deleteTenant(tenantId: string): Promise<{ message: string }> {
|
|
||||||
return apiClient.delete(`${this.baseEndpoint}/${tenantId}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get Tenant Members
|
|
||||||
*/
|
|
||||||
async getTenantMembers(
|
|
||||||
tenantId: string,
|
|
||||||
params?: BaseQueryParams
|
|
||||||
): Promise<PaginatedResponse<TenantMember>> {
|
|
||||||
return apiClient.get(`${this.baseEndpoint}/${tenantId}/members`, { params });
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Invite User to Tenant
|
|
||||||
*/
|
|
||||||
async inviteUser(tenantId: string, invitation: InviteUser): Promise<{ message: string }> {
|
|
||||||
return apiClient.post(`${this.baseEndpoint}/${tenantId}/invite`, invitation);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Remove Member from Tenant
|
|
||||||
*/
|
|
||||||
async removeMember(tenantId: string, userId: string): Promise<{ message: string }> {
|
|
||||||
return apiClient.delete(`${this.baseEndpoint}/${tenantId}/members/${userId}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update Member Role
|
|
||||||
*/
|
|
||||||
async updateMemberRole(
|
|
||||||
tenantId: string,
|
|
||||||
userId: string,
|
|
||||||
role: string
|
|
||||||
): Promise<TenantMember> {
|
|
||||||
return apiClient.patch(`${this.baseEndpoint}/${tenantId}/members/${userId}`, { role });
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get Tenant Statistics
|
|
||||||
*/
|
|
||||||
async getTenantStats(tenantId: string): Promise<TenantStats> {
|
|
||||||
return apiClient.get(`${this.baseEndpoint}/${tenantId}/stats`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get User's Tenants - Get tenants where user is owner
|
|
||||||
*/
|
|
||||||
async getUserTenants(): Promise<TenantInfo[]> {
|
|
||||||
try {
|
|
||||||
// Extract user ID from the JWT token in localStorage
|
|
||||||
const token = localStorage.getItem('auth_token');
|
|
||||||
console.log('🔑 TenantService: Auth token present:', !!token);
|
|
||||||
|
|
||||||
if (!token) {
|
|
||||||
throw new Error('No auth token found');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Decode JWT to get user ID (simple base64 decode)
|
|
||||||
const payload = JSON.parse(atob(token.split('.')[1]));
|
|
||||||
const userId = payload.user_id || payload.sub;
|
|
||||||
console.log('👤 TenantService: Extracted user ID:', userId);
|
|
||||||
|
|
||||||
if (!userId) {
|
|
||||||
throw new Error('No user ID found in token');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get tenants owned by this user
|
|
||||||
const url = `${this.baseEndpoint}/user/${userId}/owned`;
|
|
||||||
console.log('🌐 TenantService: Making request to:', url);
|
|
||||||
|
|
||||||
const result = await apiClient.get(url);
|
|
||||||
console.log('📦 TenantService: API response:', result);
|
|
||||||
console.log('📏 TenantService: Response length:', Array.isArray(result) ? result.length : 'Not an array');
|
|
||||||
|
|
||||||
// Ensure we always return an array
|
|
||||||
if (!Array.isArray(result)) {
|
|
||||||
console.warn('⚠️ TenantService: Response is not an array, converting...');
|
|
||||||
// If it's an object with numeric keys, convert to array
|
|
||||||
if (result && typeof result === 'object') {
|
|
||||||
const converted = Object.values(result);
|
|
||||||
console.log('🔄 TenantService: Converted to array:', converted);
|
|
||||||
return converted as TenantInfo[];
|
|
||||||
}
|
|
||||||
console.log('🔄 TenantService: Returning empty array');
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (result.length > 0) {
|
|
||||||
console.log('✅ TenantService: First tenant:', result[0]);
|
|
||||||
console.log('🆔 TenantService: First tenant ID:', result[0]?.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('❌ TenantService: Failed to get user tenants:', error);
|
|
||||||
// Return empty array if API call fails
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const tenantService = new TenantService();
|
|
||||||
@@ -1,161 +0,0 @@
|
|||||||
// frontend/src/api/services/training.service.ts
|
|
||||||
/**
|
|
||||||
* Training Service
|
|
||||||
* Handles ML model training operations
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { apiClient } from '../client';
|
|
||||||
import { RequestTimeouts } from '../client/config';
|
|
||||||
import type {
|
|
||||||
TrainingJobRequest,
|
|
||||||
TrainingJobResponse,
|
|
||||||
SingleProductTrainingRequest,
|
|
||||||
ModelInfo,
|
|
||||||
ModelTrainingStats,
|
|
||||||
PaginatedResponse,
|
|
||||||
BaseQueryParams,
|
|
||||||
} from '../types';
|
|
||||||
|
|
||||||
export class TrainingService {
|
|
||||||
/**
|
|
||||||
* Start Training Job for All Products
|
|
||||||
*/
|
|
||||||
async startTrainingJob(
|
|
||||||
tenantId: string,
|
|
||||||
request: TrainingJobRequest
|
|
||||||
): Promise<TrainingJobResponse> {
|
|
||||||
return apiClient.post(
|
|
||||||
`/tenants/${tenantId}/training/jobs`,
|
|
||||||
request,
|
|
||||||
{
|
|
||||||
timeout: RequestTimeouts.EXTENDED,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Start Training for Single Product
|
|
||||||
*/
|
|
||||||
async startSingleProductTraining(
|
|
||||||
tenantId: string,
|
|
||||||
request: SingleProductTrainingRequest
|
|
||||||
): Promise<TrainingJobResponse> {
|
|
||||||
return apiClient.post(
|
|
||||||
`/tenants/${tenantId}/training/single`,
|
|
||||||
request,
|
|
||||||
{
|
|
||||||
timeout: RequestTimeouts.EXTENDED,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get Training Job Status
|
|
||||||
*/
|
|
||||||
async getTrainingJobStatus(tenantId: string, jobId: string): Promise<TrainingJobResponse> {
|
|
||||||
return apiClient.get(`/tenants/${tenantId}/training/jobs/${jobId}/status`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get Training Job Logs
|
|
||||||
*/
|
|
||||||
async getTrainingJobLogs(tenantId: string, jobId: string): Promise<{ logs: string[] }> {
|
|
||||||
return apiClient.get(`/tenants/${tenantId}/training/jobs/${jobId}/logs`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Cancel Training Job
|
|
||||||
*/
|
|
||||||
async cancelTrainingJob(tenantId: string, jobId: string): Promise<{ message: string }> {
|
|
||||||
return apiClient.post(`/tenants/${tenantId}/training/jobs/${jobId}/cancel`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get Training Jobs
|
|
||||||
*/
|
|
||||||
async getTrainingJobs(
|
|
||||||
tenantId: string,
|
|
||||||
params?: BaseQueryParams & {
|
|
||||||
status?: string;
|
|
||||||
start_date?: string;
|
|
||||||
end_date?: string;
|
|
||||||
}
|
|
||||||
): Promise<PaginatedResponse<TrainingJobResponse>> {
|
|
||||||
return apiClient.get(`/tenants/${tenantId}/training/jobs`, { params });
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate Data for Training
|
|
||||||
*/
|
|
||||||
async validateTrainingData(tenantId: string): Promise<{
|
|
||||||
is_valid: boolean;
|
|
||||||
message: string;
|
|
||||||
details?: any;
|
|
||||||
}> {
|
|
||||||
return apiClient.post(`/tenants/${tenantId}/training/validate`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get Trained Models
|
|
||||||
*/
|
|
||||||
async getModels(
|
|
||||||
tenantId: string,
|
|
||||||
params?: BaseQueryParams & {
|
|
||||||
inventory_product_id?: string; // Primary way to filter by product
|
|
||||||
product_name?: string; // For backward compatibility - will need inventory service lookup
|
|
||||||
is_active?: boolean;
|
|
||||||
}
|
|
||||||
): Promise<PaginatedResponse<ModelInfo>> {
|
|
||||||
return apiClient.get(`/tenants/${tenantId}/models`, { params });
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get Model Details
|
|
||||||
*/
|
|
||||||
async getModel(tenantId: string, modelId: string): Promise<ModelInfo> {
|
|
||||||
return apiClient.get(`/tenants/${tenantId}/models/${modelId}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update Model Status
|
|
||||||
*/
|
|
||||||
async updateModelStatus(
|
|
||||||
tenantId: string,
|
|
||||||
modelId: string,
|
|
||||||
isActive: boolean
|
|
||||||
): Promise<ModelInfo> {
|
|
||||||
return apiClient.patch(`/tenants/${tenantId}/models/${modelId}`, {
|
|
||||||
is_active: isActive,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete Model
|
|
||||||
*/
|
|
||||||
async deleteModel(tenantId: string, modelId: string): Promise<{ message: string }> {
|
|
||||||
return apiClient.delete(`/tenants/${tenantId}/models/${modelId}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get Training Statistics
|
|
||||||
*/
|
|
||||||
async getTrainingStats(tenantId: string): Promise<ModelTrainingStats> {
|
|
||||||
return apiClient.get(`/tenants/${tenantId}/training/stats`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Download Model File
|
|
||||||
*/
|
|
||||||
async downloadModel(tenantId: string, modelId: string): Promise<Blob> {
|
|
||||||
const response = await apiClient.request(`/tenants/${tenantId}/models/${modelId}/download`, {
|
|
||||||
method: 'GET',
|
|
||||||
headers: {
|
|
||||||
'Accept': 'application/octet-stream',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return new Blob([response]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const trainingService = new TrainingService();
|
|
||||||
@@ -1,94 +0,0 @@
|
|||||||
// frontend/src/api/types/auth.ts
|
|
||||||
/**
|
|
||||||
* Authentication Types
|
|
||||||
*/
|
|
||||||
|
|
||||||
export interface UserData {
|
|
||||||
id: string;
|
|
||||||
email: string;
|
|
||||||
full_name: string;
|
|
||||||
is_active: boolean;
|
|
||||||
is_verified: boolean;
|
|
||||||
created_at: string;
|
|
||||||
last_login?: string;
|
|
||||||
phone?: string;
|
|
||||||
language?: string;
|
|
||||||
timezone?: string;
|
|
||||||
tenant_id?: string;
|
|
||||||
role: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface User {
|
|
||||||
id: string;
|
|
||||||
email: string;
|
|
||||||
fullName: string;
|
|
||||||
role: "owner" | "admin" | "manager" | "worker";
|
|
||||||
isOnboardingComplete: boolean;
|
|
||||||
tenant_id: string;
|
|
||||||
created_at?: string;
|
|
||||||
last_login?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface LoginRequest {
|
|
||||||
email: string;
|
|
||||||
password: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RegisterRequest {
|
|
||||||
email: string;
|
|
||||||
password: string;
|
|
||||||
full_name: string;
|
|
||||||
role?: string;
|
|
||||||
phone?: string;
|
|
||||||
language?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface LoginResponse {
|
|
||||||
access_token: string;
|
|
||||||
refresh_token?: string;
|
|
||||||
token_type: string;
|
|
||||||
expires_in: number;
|
|
||||||
user?: UserData;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UserResponse {
|
|
||||||
id: string;
|
|
||||||
email: string;
|
|
||||||
full_name: string;
|
|
||||||
is_active: boolean;
|
|
||||||
is_verified: boolean;
|
|
||||||
created_at: string;
|
|
||||||
last_login?: string;
|
|
||||||
phone?: string;
|
|
||||||
language?: string;
|
|
||||||
timezone?: string;
|
|
||||||
tenant_id?: string;
|
|
||||||
role?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TokenVerification {
|
|
||||||
valid: boolean;
|
|
||||||
user_id?: string;
|
|
||||||
email?: string;
|
|
||||||
exp?: number;
|
|
||||||
message?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PasswordResetRequest {
|
|
||||||
email: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PasswordResetResponse {
|
|
||||||
message: string;
|
|
||||||
reset_token?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PasswordResetConfirmRequest {
|
|
||||||
token: string;
|
|
||||||
new_password: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface LogoutResponse {
|
|
||||||
message: string;
|
|
||||||
success: boolean;
|
|
||||||
}
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
// frontend/src/api/types/common.ts
|
|
||||||
/**
|
|
||||||
* Common API Types
|
|
||||||
*/
|
|
||||||
|
|
||||||
export interface ApiResponse<T = any> {
|
|
||||||
data?: T;
|
|
||||||
message?: string;
|
|
||||||
status: string;
|
|
||||||
timestamp?: string;
|
|
||||||
meta?: PaginationMeta;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ApiError {
|
|
||||||
detail: string;
|
|
||||||
service?: string;
|
|
||||||
error_code?: string;
|
|
||||||
timestamp?: string;
|
|
||||||
field?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PaginationMeta {
|
|
||||||
page: number;
|
|
||||||
limit: number;
|
|
||||||
total: number;
|
|
||||||
totalPages: number;
|
|
||||||
hasNext: boolean;
|
|
||||||
hasPrev: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PaginatedResponse<T> {
|
|
||||||
data: T[];
|
|
||||||
pagination: PaginationMeta;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BaseQueryParams {
|
|
||||||
page?: number;
|
|
||||||
limit?: number;
|
|
||||||
search?: string;
|
|
||||||
sort?: string;
|
|
||||||
order?: 'asc' | 'desc';
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CreateResponse<T = any> {
|
|
||||||
data: T;
|
|
||||||
message?: string;
|
|
||||||
status: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UpdateResponse<T = any> {
|
|
||||||
data: T;
|
|
||||||
message?: string;
|
|
||||||
status: string;
|
|
||||||
}
|
|
||||||
@@ -1,148 +0,0 @@
|
|||||||
// frontend/src/api/types/data.ts
|
|
||||||
/**
|
|
||||||
* Data Management Types
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { BaseQueryParams } from './common';
|
|
||||||
|
|
||||||
export interface ProductInfo {
|
|
||||||
inventory_product_id: string;
|
|
||||||
name: string;
|
|
||||||
category?: string;
|
|
||||||
sales_count?: number;
|
|
||||||
total_quantity?: number;
|
|
||||||
last_sale_date?: string;
|
|
||||||
// Additional inventory fields
|
|
||||||
current_stock?: number;
|
|
||||||
unit?: string;
|
|
||||||
cost_per_unit?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SalesData {
|
|
||||||
id: string;
|
|
||||||
tenant_id: string;
|
|
||||||
date: string;
|
|
||||||
inventory_product_id: string; // Reference to inventory service product
|
|
||||||
// Note: product_name now needs to be fetched from inventory service using inventory_product_id
|
|
||||||
product_name?: string; // Optional - for backward compatibility, populated by frontend logic
|
|
||||||
category?: string; // Optional - fetched from inventory service
|
|
||||||
quantity: number;
|
|
||||||
unit_price: number;
|
|
||||||
total_revenue: number;
|
|
||||||
location_id?: string;
|
|
||||||
source: string;
|
|
||||||
created_at: string;
|
|
||||||
external_factors?: ExternalFactors;
|
|
||||||
// Additional properties used by components
|
|
||||||
sales_channel?: string;
|
|
||||||
is_validated?: boolean;
|
|
||||||
cost_of_goods?: number;
|
|
||||||
revenue?: number;
|
|
||||||
quantity_sold?: number;
|
|
||||||
discount_applied?: number;
|
|
||||||
weather_condition?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SalesValidationResult {
|
|
||||||
is_valid: boolean;
|
|
||||||
total_records: number;
|
|
||||||
valid_records: number;
|
|
||||||
invalid_records: number;
|
|
||||||
errors: ValidationError[];
|
|
||||||
warnings: ValidationError[];
|
|
||||||
summary: Record<string, any>;
|
|
||||||
message?: string;
|
|
||||||
details?: Record<string, any>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ExternalFactors {
|
|
||||||
weather_temperature?: number;
|
|
||||||
weather_precipitation?: number;
|
|
||||||
weather_description?: string;
|
|
||||||
traffic_volume?: number;
|
|
||||||
is_holiday?: boolean;
|
|
||||||
is_weekend?: boolean;
|
|
||||||
day_of_week?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SalesDataQuery extends BaseQueryParams {
|
|
||||||
tenant_id: string;
|
|
||||||
start_date?: string;
|
|
||||||
end_date?: string;
|
|
||||||
// Note: product_names filtering now requires inventory service integration or use inventory_product_ids
|
|
||||||
product_names?: string[]; // For backward compatibility - will need inventory service lookup
|
|
||||||
inventory_product_ids?: string[]; // Primary way to filter by products
|
|
||||||
location_ids?: string[];
|
|
||||||
sources?: string[];
|
|
||||||
min_quantity?: number;
|
|
||||||
max_quantity?: number;
|
|
||||||
min_revenue?: number;
|
|
||||||
max_revenue?: number;
|
|
||||||
search_term?: string;
|
|
||||||
sales_channel?: string;
|
|
||||||
inventory_product_id?: string; // Single product filter
|
|
||||||
is_validated?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SalesDataImport {
|
|
||||||
tenant_id?: string;
|
|
||||||
data: string;
|
|
||||||
data_format: 'csv' | 'json' | 'excel';
|
|
||||||
source?: string;
|
|
||||||
validate_only?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SalesImportResult {
|
|
||||||
success: boolean;
|
|
||||||
message: string;
|
|
||||||
imported_count: number;
|
|
||||||
records_imported: number;
|
|
||||||
skipped_count: number;
|
|
||||||
error_count: number;
|
|
||||||
validation_errors?: ValidationError[];
|
|
||||||
preview_data?: SalesData[];
|
|
||||||
file_info?: FileInfo;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ValidationError {
|
|
||||||
row: number;
|
|
||||||
field: string;
|
|
||||||
value: any;
|
|
||||||
message: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface FileInfo {
|
|
||||||
filename: string;
|
|
||||||
size: number;
|
|
||||||
rows: number;
|
|
||||||
format: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DashboardStats {
|
|
||||||
total_sales: number;
|
|
||||||
total_revenue: number;
|
|
||||||
total_products: number;
|
|
||||||
date_range: {
|
|
||||||
start: string;
|
|
||||||
end: string;
|
|
||||||
};
|
|
||||||
top_products: ProductStats[];
|
|
||||||
recent_activity: ActivityItem[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ProductStats {
|
|
||||||
inventory_product_id: string; // Reference to inventory service product
|
|
||||||
product_name?: string; // Optional - for display, populated by frontend from inventory service
|
|
||||||
total_quantity: number;
|
|
||||||
total_revenue: number;
|
|
||||||
avg_price: number;
|
|
||||||
sales_trend: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ActivityItem {
|
|
||||||
id: string;
|
|
||||||
type: string;
|
|
||||||
description: string;
|
|
||||||
timestamp: string;
|
|
||||||
metadata?: Record<string, any>;
|
|
||||||
}
|
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
// frontend/src/api/types/forecasting.ts
|
|
||||||
/**
|
|
||||||
* Forecasting Service Types
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { ExternalFactors } from './data';
|
|
||||||
|
|
||||||
export interface SingleForecastRequest {
|
|
||||||
inventory_product_id: string;
|
|
||||||
forecast_date: string;
|
|
||||||
forecast_days: number;
|
|
||||||
location: string;
|
|
||||||
include_external_factors?: boolean;
|
|
||||||
confidence_intervals?: boolean;
|
|
||||||
// Note: confidence_level is handled internally by backend (0.8 default)
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BatchForecastRequest {
|
|
||||||
inventory_product_ids?: string[]; // Primary way to specify products
|
|
||||||
product_names?: string[]; // For backward compatibility - will need inventory service lookup
|
|
||||||
forecast_date: string;
|
|
||||||
forecast_days: number;
|
|
||||||
location: string;
|
|
||||||
include_external_factors?: boolean;
|
|
||||||
confidence_intervals?: boolean;
|
|
||||||
batch_name?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ForecastResponse {
|
|
||||||
id: string;
|
|
||||||
tenant_id: string;
|
|
||||||
inventory_product_id: string;
|
|
||||||
product_name?: string; // Optional - for display, populated by frontend from inventory service
|
|
||||||
forecast_date: string;
|
|
||||||
predicted_demand: number;
|
|
||||||
confidence_lower?: number;
|
|
||||||
confidence_upper?: number;
|
|
||||||
model_id: string;
|
|
||||||
confidence_level?: number;
|
|
||||||
external_factors?: ExternalFactors;
|
|
||||||
created_at: string;
|
|
||||||
processing_time_ms?: number;
|
|
||||||
features_used?: Record<string, any>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BatchForecastResponse {
|
|
||||||
id: string;
|
|
||||||
tenant_id: string;
|
|
||||||
batch_name: string;
|
|
||||||
status: BatchForecastStatus;
|
|
||||||
total_products: number;
|
|
||||||
completed_products: number;
|
|
||||||
failed_products: number;
|
|
||||||
requested_at: string;
|
|
||||||
completed_at?: string;
|
|
||||||
processing_time_ms?: number;
|
|
||||||
forecasts?: ForecastResponse[];
|
|
||||||
error_message?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type BatchForecastStatus =
|
|
||||||
| 'pending'
|
|
||||||
| 'processing'
|
|
||||||
| 'completed'
|
|
||||||
| 'failed'
|
|
||||||
| 'cancelled';
|
|
||||||
|
|
||||||
export interface ForecastAlert {
|
|
||||||
id: string;
|
|
||||||
tenant_id: string;
|
|
||||||
forecast_id: string;
|
|
||||||
alert_type: string;
|
|
||||||
severity: 'low' | 'medium' | 'high' | 'critical';
|
|
||||||
message: string;
|
|
||||||
is_active: boolean;
|
|
||||||
created_at: string;
|
|
||||||
acknowledged_at?: string;
|
|
||||||
notification_sent: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface QuickForecast {
|
|
||||||
inventory_product_id: string;
|
|
||||||
product_name?: string; // Optional - for display, populated by frontend from inventory service
|
|
||||||
next_day_prediction: number;
|
|
||||||
next_week_avg: number;
|
|
||||||
trend_direction: 'up' | 'down' | 'stable';
|
|
||||||
confidence_score: number;
|
|
||||||
last_updated: string;
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
// frontend/src/api/types/index.ts
|
|
||||||
/**
|
|
||||||
* Main Types Export
|
|
||||||
*/
|
|
||||||
|
|
||||||
// Re-export all types
|
|
||||||
export * from './common';
|
|
||||||
export * from './auth';
|
|
||||||
export * from './tenant';
|
|
||||||
export * from './data';
|
|
||||||
export * from './training';
|
|
||||||
export * from './forecasting';
|
|
||||||
export * from './notification';
|
|
||||||
export * from './procurement';
|
|
||||||
@@ -1,108 +0,0 @@
|
|||||||
// frontend/src/api/types/notification.ts
|
|
||||||
/**
|
|
||||||
* Notification Service Types
|
|
||||||
*/
|
|
||||||
|
|
||||||
export interface NotificationCreate {
|
|
||||||
recipient_id?: string;
|
|
||||||
recipient_email?: string;
|
|
||||||
recipient_phone?: string;
|
|
||||||
channel: NotificationChannel;
|
|
||||||
template_id?: string;
|
|
||||||
subject?: string;
|
|
||||||
message: string;
|
|
||||||
data?: Record<string, any>;
|
|
||||||
scheduled_for?: string;
|
|
||||||
priority?: NotificationPriority;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface NotificationResponse {
|
|
||||||
id: string;
|
|
||||||
tenant_id: string;
|
|
||||||
recipient_id?: string;
|
|
||||||
recipient_email?: string;
|
|
||||||
recipient_phone?: string;
|
|
||||||
channel: NotificationChannel;
|
|
||||||
template_id?: string;
|
|
||||||
subject?: string;
|
|
||||||
message: string;
|
|
||||||
status: NotificationStatus;
|
|
||||||
priority: NotificationPriority;
|
|
||||||
data?: Record<string, any>;
|
|
||||||
scheduled_for?: string;
|
|
||||||
sent_at?: string;
|
|
||||||
delivered_at?: string;
|
|
||||||
failed_at?: string;
|
|
||||||
error_message?: string;
|
|
||||||
created_at: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type NotificationChannel = 'email' | 'whatsapp' | 'push' | 'sms';
|
|
||||||
export type NotificationStatus =
|
|
||||||
| 'pending'
|
|
||||||
| 'scheduled'
|
|
||||||
| 'sent'
|
|
||||||
| 'delivered'
|
|
||||||
| 'failed'
|
|
||||||
| 'cancelled';
|
|
||||||
export type NotificationPriority = 'low' | 'normal' | 'high' | 'urgent';
|
|
||||||
|
|
||||||
export interface NotificationTemplate {
|
|
||||||
id: string;
|
|
||||||
tenant_id: string;
|
|
||||||
name: string;
|
|
||||||
channel: NotificationChannel;
|
|
||||||
subject_template?: string;
|
|
||||||
body_template: string;
|
|
||||||
variables: string[];
|
|
||||||
is_system: boolean;
|
|
||||||
is_active: boolean;
|
|
||||||
created_at: string;
|
|
||||||
updated_at?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface NotificationHistory {
|
|
||||||
id: string;
|
|
||||||
notification_id: string;
|
|
||||||
status: NotificationStatus;
|
|
||||||
timestamp: string;
|
|
||||||
details?: string;
|
|
||||||
metadata?: Record<string, any>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface NotificationStats {
|
|
||||||
total_sent: number;
|
|
||||||
total_delivered: number;
|
|
||||||
total_failed: number;
|
|
||||||
delivery_rate: number;
|
|
||||||
channels_breakdown: Record<NotificationChannel, number>;
|
|
||||||
recent_activity: NotificationResponse[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BulkNotificationRequest {
|
|
||||||
recipients: BulkRecipient[];
|
|
||||||
channel: NotificationChannel;
|
|
||||||
template_id?: string;
|
|
||||||
subject?: string;
|
|
||||||
message: string;
|
|
||||||
scheduled_for?: string;
|
|
||||||
priority?: NotificationPriority;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BulkRecipient {
|
|
||||||
recipient_id?: string;
|
|
||||||
recipient_email?: string;
|
|
||||||
recipient_phone?: string;
|
|
||||||
data?: Record<string, any>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BulkNotificationStatus {
|
|
||||||
batch_id: string;
|
|
||||||
total_recipients: number;
|
|
||||||
sent_count: number;
|
|
||||||
failed_count: number;
|
|
||||||
pending_count: number;
|
|
||||||
status: 'processing' | 'completed' | 'failed';
|
|
||||||
created_at: string;
|
|
||||||
completed_at?: string;
|
|
||||||
}
|
|
||||||
@@ -1,330 +0,0 @@
|
|||||||
// ================================================================
|
|
||||||
// frontend/src/api/types/procurement.ts
|
|
||||||
// ================================================================
|
|
||||||
/**
|
|
||||||
* TypeScript types for procurement planning API
|
|
||||||
*/
|
|
||||||
|
|
||||||
// ================================================================
|
|
||||||
// BASE TYPES
|
|
||||||
// ================================================================
|
|
||||||
|
|
||||||
export interface ProcurementRequirement {
|
|
||||||
id: string;
|
|
||||||
plan_id: string;
|
|
||||||
requirement_number: string;
|
|
||||||
|
|
||||||
// Product information
|
|
||||||
product_id: string;
|
|
||||||
product_name: string;
|
|
||||||
product_sku?: string;
|
|
||||||
product_category?: string;
|
|
||||||
product_type: string;
|
|
||||||
|
|
||||||
// Quantity requirements
|
|
||||||
required_quantity: number;
|
|
||||||
unit_of_measure: string;
|
|
||||||
safety_stock_quantity: number;
|
|
||||||
total_quantity_needed: number;
|
|
||||||
|
|
||||||
// Current inventory situation
|
|
||||||
current_stock_level: number;
|
|
||||||
reserved_stock: number;
|
|
||||||
available_stock: number;
|
|
||||||
net_requirement: number;
|
|
||||||
|
|
||||||
// Demand breakdown
|
|
||||||
order_demand: number;
|
|
||||||
production_demand: number;
|
|
||||||
forecast_demand: number;
|
|
||||||
buffer_demand: number;
|
|
||||||
|
|
||||||
// Supplier information
|
|
||||||
preferred_supplier_id?: string;
|
|
||||||
backup_supplier_id?: string;
|
|
||||||
supplier_name?: string;
|
|
||||||
supplier_lead_time_days?: number;
|
|
||||||
minimum_order_quantity?: number;
|
|
||||||
|
|
||||||
// Pricing
|
|
||||||
estimated_unit_cost?: number;
|
|
||||||
estimated_total_cost?: number;
|
|
||||||
last_purchase_cost?: number;
|
|
||||||
cost_variance: number;
|
|
||||||
|
|
||||||
// Timing
|
|
||||||
required_by_date: string;
|
|
||||||
lead_time_buffer_days: number;
|
|
||||||
suggested_order_date: string;
|
|
||||||
latest_order_date: string;
|
|
||||||
|
|
||||||
// Status and priority
|
|
||||||
status: string;
|
|
||||||
priority: string;
|
|
||||||
risk_level: string;
|
|
||||||
|
|
||||||
// Purchase tracking
|
|
||||||
purchase_order_id?: string;
|
|
||||||
purchase_order_number?: string;
|
|
||||||
ordered_quantity: number;
|
|
||||||
ordered_at?: string;
|
|
||||||
|
|
||||||
// Delivery tracking
|
|
||||||
expected_delivery_date?: string;
|
|
||||||
actual_delivery_date?: string;
|
|
||||||
received_quantity: number;
|
|
||||||
delivery_status: string;
|
|
||||||
|
|
||||||
// Performance metrics
|
|
||||||
fulfillment_rate?: number;
|
|
||||||
on_time_delivery?: boolean;
|
|
||||||
quality_rating?: number;
|
|
||||||
|
|
||||||
// Approval
|
|
||||||
approved_quantity?: number;
|
|
||||||
approved_cost?: number;
|
|
||||||
approved_at?: string;
|
|
||||||
approved_by?: string;
|
|
||||||
|
|
||||||
// Additional info
|
|
||||||
special_requirements?: string;
|
|
||||||
storage_requirements?: string;
|
|
||||||
shelf_life_days?: number;
|
|
||||||
quality_specifications?: Record<string, any>;
|
|
||||||
procurement_notes?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ProcurementPlan {
|
|
||||||
id: string;
|
|
||||||
tenant_id: string;
|
|
||||||
plan_number: string;
|
|
||||||
|
|
||||||
// Plan scope and timing
|
|
||||||
plan_date: string;
|
|
||||||
plan_period_start: string;
|
|
||||||
plan_period_end: string;
|
|
||||||
planning_horizon_days: number;
|
|
||||||
|
|
||||||
// Plan status and lifecycle
|
|
||||||
status: string;
|
|
||||||
plan_type: string;
|
|
||||||
priority: string;
|
|
||||||
|
|
||||||
// Business context
|
|
||||||
business_model?: string;
|
|
||||||
procurement_strategy: string;
|
|
||||||
|
|
||||||
// Plan totals and summary
|
|
||||||
total_requirements: number;
|
|
||||||
total_estimated_cost: number;
|
|
||||||
total_approved_cost: number;
|
|
||||||
cost_variance: number;
|
|
||||||
|
|
||||||
// Demand analysis
|
|
||||||
total_demand_orders: number;
|
|
||||||
total_demand_quantity: number;
|
|
||||||
total_production_requirements: number;
|
|
||||||
safety_stock_buffer: number;
|
|
||||||
|
|
||||||
// Supplier coordination
|
|
||||||
primary_suppliers_count: number;
|
|
||||||
backup_suppliers_count: number;
|
|
||||||
supplier_diversification_score?: number;
|
|
||||||
|
|
||||||
// Risk assessment
|
|
||||||
supply_risk_level: string;
|
|
||||||
demand_forecast_confidence?: number;
|
|
||||||
seasonality_adjustment: number;
|
|
||||||
|
|
||||||
// Execution tracking
|
|
||||||
approved_at?: string;
|
|
||||||
approved_by?: string;
|
|
||||||
execution_started_at?: string;
|
|
||||||
execution_completed_at?: string;
|
|
||||||
|
|
||||||
// Performance metrics
|
|
||||||
fulfillment_rate?: number;
|
|
||||||
on_time_delivery_rate?: number;
|
|
||||||
cost_accuracy?: number;
|
|
||||||
quality_score?: number;
|
|
||||||
|
|
||||||
// Metadata
|
|
||||||
created_at: string;
|
|
||||||
updated_at: string;
|
|
||||||
created_by?: string;
|
|
||||||
updated_by?: string;
|
|
||||||
|
|
||||||
// Additional info
|
|
||||||
special_requirements?: string;
|
|
||||||
|
|
||||||
// Relationships
|
|
||||||
requirements: ProcurementRequirement[];
|
|
||||||
}
|
|
||||||
|
|
||||||
// ================================================================
|
|
||||||
// REQUEST TYPES
|
|
||||||
// ================================================================
|
|
||||||
|
|
||||||
export interface GeneratePlanRequest {
|
|
||||||
plan_date?: string;
|
|
||||||
force_regenerate?: boolean;
|
|
||||||
planning_horizon_days?: number;
|
|
||||||
include_safety_stock?: boolean;
|
|
||||||
safety_stock_percentage?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ForecastRequest {
|
|
||||||
target_date: string;
|
|
||||||
horizon_days?: number;
|
|
||||||
include_confidence_intervals?: boolean;
|
|
||||||
product_ids?: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
// ================================================================
|
|
||||||
// RESPONSE TYPES
|
|
||||||
// ================================================================
|
|
||||||
|
|
||||||
export interface GeneratePlanResponse {
|
|
||||||
success: boolean;
|
|
||||||
message: string;
|
|
||||||
plan?: ProcurementPlan;
|
|
||||||
warnings?: string[];
|
|
||||||
errors?: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PaginatedProcurementPlans {
|
|
||||||
plans: ProcurementPlan[];
|
|
||||||
total: number;
|
|
||||||
page: number;
|
|
||||||
limit: number;
|
|
||||||
has_more: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ================================================================
|
|
||||||
// DASHBOARD TYPES
|
|
||||||
// ================================================================
|
|
||||||
|
|
||||||
export interface ProcurementSummary {
|
|
||||||
total_plans: number;
|
|
||||||
active_plans: number;
|
|
||||||
total_requirements: number;
|
|
||||||
pending_requirements: number;
|
|
||||||
critical_requirements: number;
|
|
||||||
|
|
||||||
total_estimated_cost: number;
|
|
||||||
total_approved_cost: number;
|
|
||||||
cost_variance: number;
|
|
||||||
|
|
||||||
average_fulfillment_rate?: number;
|
|
||||||
average_on_time_delivery?: number;
|
|
||||||
|
|
||||||
top_suppliers: Array<Record<string, any>>;
|
|
||||||
critical_items: Array<Record<string, any>>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DashboardData {
|
|
||||||
current_plan?: ProcurementPlan;
|
|
||||||
summary: ProcurementSummary;
|
|
||||||
|
|
||||||
upcoming_deliveries: Array<Record<string, any>>;
|
|
||||||
overdue_requirements: Array<Record<string, any>>;
|
|
||||||
low_stock_alerts: Array<Record<string, any>>;
|
|
||||||
|
|
||||||
performance_metrics: Record<string, any>;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ================================================================
|
|
||||||
// FILTER AND SEARCH TYPES
|
|
||||||
// ================================================================
|
|
||||||
|
|
||||||
export interface ProcurementFilters {
|
|
||||||
status?: string[];
|
|
||||||
priority?: string[];
|
|
||||||
risk_level?: string[];
|
|
||||||
supplier_id?: string;
|
|
||||||
product_category?: string;
|
|
||||||
date_range?: {
|
|
||||||
start: string;
|
|
||||||
end: string;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RequirementFilters {
|
|
||||||
status?: string[];
|
|
||||||
priority?: string[];
|
|
||||||
product_type?: string[];
|
|
||||||
overdue_only?: boolean;
|
|
||||||
critical_only?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ================================================================
|
|
||||||
// UI COMPONENT TYPES
|
|
||||||
// ================================================================
|
|
||||||
|
|
||||||
export interface ProcurementPlanCardProps {
|
|
||||||
plan: ProcurementPlan;
|
|
||||||
onViewDetails?: (planId: string) => void;
|
|
||||||
onUpdateStatus?: (planId: string, status: string) => void;
|
|
||||||
showActions?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RequirementCardProps {
|
|
||||||
requirement: ProcurementRequirement;
|
|
||||||
onViewDetails?: (requirementId: string) => void;
|
|
||||||
onUpdateStatus?: (requirementId: string, status: string) => void;
|
|
||||||
showSupplierInfo?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ProcurementDashboardProps {
|
|
||||||
showFilters?: boolean;
|
|
||||||
refreshInterval?: number;
|
|
||||||
onPlanGenerated?: (plan: ProcurementPlan) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ================================================================
|
|
||||||
// ENUMS
|
|
||||||
// ================================================================
|
|
||||||
|
|
||||||
export enum PlanStatus {
|
|
||||||
DRAFT = 'draft',
|
|
||||||
PENDING_APPROVAL = 'pending_approval',
|
|
||||||
APPROVED = 'approved',
|
|
||||||
IN_EXECUTION = 'in_execution',
|
|
||||||
COMPLETED = 'completed',
|
|
||||||
CANCELLED = 'cancelled'
|
|
||||||
}
|
|
||||||
|
|
||||||
export enum RequirementStatus {
|
|
||||||
PENDING = 'pending',
|
|
||||||
APPROVED = 'approved',
|
|
||||||
ORDERED = 'ordered',
|
|
||||||
PARTIALLY_RECEIVED = 'partially_received',
|
|
||||||
RECEIVED = 'received',
|
|
||||||
CANCELLED = 'cancelled'
|
|
||||||
}
|
|
||||||
|
|
||||||
export enum Priority {
|
|
||||||
CRITICAL = 'critical',
|
|
||||||
HIGH = 'high',
|
|
||||||
NORMAL = 'normal',
|
|
||||||
LOW = 'low'
|
|
||||||
}
|
|
||||||
|
|
||||||
export enum RiskLevel {
|
|
||||||
LOW = 'low',
|
|
||||||
MEDIUM = 'medium',
|
|
||||||
HIGH = 'high',
|
|
||||||
CRITICAL = 'critical'
|
|
||||||
}
|
|
||||||
|
|
||||||
export enum PlanType {
|
|
||||||
REGULAR = 'regular',
|
|
||||||
EMERGENCY = 'emergency',
|
|
||||||
SEASONAL = 'seasonal'
|
|
||||||
}
|
|
||||||
|
|
||||||
export enum ProductType {
|
|
||||||
INGREDIENT = 'ingredient',
|
|
||||||
PACKAGING = 'packaging',
|
|
||||||
SUPPLIES = 'supplies'
|
|
||||||
}
|
|
||||||
@@ -1,143 +0,0 @@
|
|||||||
// frontend/src/api/types/tenant.ts
|
|
||||||
/**
|
|
||||||
* Tenant Management Types
|
|
||||||
*/
|
|
||||||
|
|
||||||
export interface TenantInfo {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
description?: string;
|
|
||||||
owner_id: string;
|
|
||||||
is_active: boolean;
|
|
||||||
created_at: string;
|
|
||||||
updated_at?: string;
|
|
||||||
settings?: TenantSettings;
|
|
||||||
subscription?: TenantSubscription;
|
|
||||||
location?: TenantLocation;
|
|
||||||
business_type?: 'bakery' | 'coffee_shop' | 'pastry_shop' | 'restaurant';
|
|
||||||
business_model?: 'individual_bakery' | 'central_baker_satellite' | 'retail_bakery' | 'hybrid_bakery';
|
|
||||||
// Added properties for compatibility
|
|
||||||
address?: string;
|
|
||||||
products?: any[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Tenant {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
business_type: string;
|
|
||||||
address: string;
|
|
||||||
products: any[];
|
|
||||||
created_at: string;
|
|
||||||
updated_at: string;
|
|
||||||
owner_id: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TenantSettings {
|
|
||||||
language: string;
|
|
||||||
timezone: string;
|
|
||||||
currency: string;
|
|
||||||
date_format: string;
|
|
||||||
notification_preferences: Record<string, boolean>;
|
|
||||||
business_hours: BusinessHours;
|
|
||||||
operating_hours?: BusinessHours;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BusinessHours {
|
|
||||||
monday: DaySchedule;
|
|
||||||
tuesday: DaySchedule;
|
|
||||||
wednesday: DaySchedule;
|
|
||||||
thursday: DaySchedule;
|
|
||||||
friday: DaySchedule;
|
|
||||||
saturday: DaySchedule;
|
|
||||||
sunday: DaySchedule;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DaySchedule {
|
|
||||||
open: string;
|
|
||||||
close: string;
|
|
||||||
closed: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TenantLocation {
|
|
||||||
address: string;
|
|
||||||
city: string;
|
|
||||||
country: string;
|
|
||||||
postal_code: string;
|
|
||||||
latitude?: number;
|
|
||||||
longitude?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TenantSubscription {
|
|
||||||
plan: string;
|
|
||||||
status: string;
|
|
||||||
billing_cycle: string;
|
|
||||||
current_period_start: string;
|
|
||||||
current_period_end: string;
|
|
||||||
cancel_at_period_end: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TenantCreate {
|
|
||||||
name: string;
|
|
||||||
address?: string;
|
|
||||||
business_type?: 'bakery' | 'coffee_shop' | 'pastry_shop' | 'restaurant';
|
|
||||||
business_model?: 'individual_bakery' | 'central_baker_satellite' | 'retail_bakery' | 'hybrid_bakery';
|
|
||||||
postal_code: string;
|
|
||||||
phone: string;
|
|
||||||
description?: string;
|
|
||||||
settings?: Partial<TenantSettings>;
|
|
||||||
location?: TenantLocation;
|
|
||||||
coordinates?: { lat: number; lng: number };
|
|
||||||
products?: string[];
|
|
||||||
has_historical_data?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TenantUpdate {
|
|
||||||
name?: string;
|
|
||||||
description?: string;
|
|
||||||
settings?: Partial<TenantSettings>;
|
|
||||||
location?: TenantLocation;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TenantMember {
|
|
||||||
user_id: string;
|
|
||||||
tenant_id: string;
|
|
||||||
role: 'owner' | 'admin' | 'member' | 'viewer';
|
|
||||||
is_active: boolean;
|
|
||||||
joined_at: string;
|
|
||||||
user: {
|
|
||||||
id: string;
|
|
||||||
email: string;
|
|
||||||
full_name: string;
|
|
||||||
};
|
|
||||||
// Additional properties for compatibility
|
|
||||||
id?: string;
|
|
||||||
status?: 'active' | 'inactive' | 'pending';
|
|
||||||
last_active?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UserMember {
|
|
||||||
id: string;
|
|
||||||
email: string;
|
|
||||||
full_name: string;
|
|
||||||
role: 'owner' | 'admin' | 'member' | 'viewer';
|
|
||||||
status: 'active' | 'inactive' | 'pending';
|
|
||||||
joined_at: string;
|
|
||||||
last_active?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface InviteUser {
|
|
||||||
email: string;
|
|
||||||
role: 'admin' | 'member' | 'viewer';
|
|
||||||
message?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TenantStats {
|
|
||||||
tenant_id: string;
|
|
||||||
total_members: number;
|
|
||||||
active_members: number;
|
|
||||||
total_predictions: number;
|
|
||||||
models_trained: number;
|
|
||||||
last_training_date?: string;
|
|
||||||
subscription_plan: string;
|
|
||||||
subscription_status: string;
|
|
||||||
}
|
|
||||||
@@ -1,132 +0,0 @@
|
|||||||
// frontend/src/api/types/training.ts
|
|
||||||
/**
|
|
||||||
* Training Service Types
|
|
||||||
*/
|
|
||||||
|
|
||||||
export interface TrainingJobRequest {
|
|
||||||
config?: TrainingJobConfig;
|
|
||||||
priority?: number;
|
|
||||||
schedule_time?: string;
|
|
||||||
include_weather?: boolean;
|
|
||||||
include_traffic?: boolean;
|
|
||||||
min_data_points?: number;
|
|
||||||
use_default_data?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SingleProductTrainingRequest {
|
|
||||||
inventory_product_id: string;
|
|
||||||
config?: TrainingJobConfig;
|
|
||||||
priority?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TrainingJobConfig {
|
|
||||||
external_data?: ExternalDataConfig;
|
|
||||||
prophet_params?: Record<string, any>;
|
|
||||||
data_filters?: Record<string, any>;
|
|
||||||
validation_params?: Record<string, any>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ExternalDataConfig {
|
|
||||||
weather_enabled: boolean;
|
|
||||||
traffic_enabled: boolean;
|
|
||||||
weather_features: string[];
|
|
||||||
traffic_features: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TrainingJobResponse {
|
|
||||||
job_id: string;
|
|
||||||
tenant_id: string;
|
|
||||||
status: TrainingJobStatus;
|
|
||||||
config: TrainingJobConfig;
|
|
||||||
priority: number;
|
|
||||||
created_at: string;
|
|
||||||
started_at?: string;
|
|
||||||
completed_at?: string;
|
|
||||||
error_message?: string;
|
|
||||||
progress?: TrainingJobProgress;
|
|
||||||
results?: TrainingJobResults;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type TrainingJobStatus =
|
|
||||||
| 'pending'
|
|
||||||
| 'running'
|
|
||||||
| 'completed'
|
|
||||||
| 'failed'
|
|
||||||
| 'cancelled';
|
|
||||||
|
|
||||||
export interface TrainingJobProgress {
|
|
||||||
current_step: string;
|
|
||||||
total_steps: number;
|
|
||||||
completed_steps: number;
|
|
||||||
percentage: number;
|
|
||||||
current_product?: string;
|
|
||||||
total_products?: number;
|
|
||||||
completed_products?: number;
|
|
||||||
estimated_completion?: string;
|
|
||||||
detailed_progress?: StepProgress[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface StepProgress {
|
|
||||||
step_name: string;
|
|
||||||
status: 'pending' | 'running' | 'completed' | 'failed';
|
|
||||||
progress_percentage: number;
|
|
||||||
start_time?: string;
|
|
||||||
end_time?: string;
|
|
||||||
duration_seconds?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TrainingJobResults {
|
|
||||||
models_trained: number;
|
|
||||||
models_failed: number;
|
|
||||||
total_training_time_seconds: number;
|
|
||||||
average_model_accuracy?: number;
|
|
||||||
trained_models: TrainedModelInfo[];
|
|
||||||
failed_products?: string[]; // inventory_product_ids of failed products
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TrainedModelInfo {
|
|
||||||
inventory_product_id: string;
|
|
||||||
product_name?: string; // Optional - for display, populated by frontend from inventory service
|
|
||||||
model_id: string;
|
|
||||||
model_type: string;
|
|
||||||
accuracy_metrics: TrainingMetrics;
|
|
||||||
training_time_seconds: number;
|
|
||||||
data_points_used: number;
|
|
||||||
model_path: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TrainingMetrics {
|
|
||||||
mae: number;
|
|
||||||
mse: number;
|
|
||||||
rmse: number;
|
|
||||||
mape: number;
|
|
||||||
r2_score: number;
|
|
||||||
mean_actual: number;
|
|
||||||
mean_predicted: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ModelInfo {
|
|
||||||
model_id: string;
|
|
||||||
tenant_id: string;
|
|
||||||
inventory_product_id: string;
|
|
||||||
product_name?: string; // Optional - for display, populated by frontend from inventory service
|
|
||||||
model_type: string;
|
|
||||||
model_path: string;
|
|
||||||
version: number;
|
|
||||||
training_samples: number;
|
|
||||||
features: string[];
|
|
||||||
hyperparameters: Record<string, any>;
|
|
||||||
training_metrics: Record<string, number>;
|
|
||||||
is_active: boolean;
|
|
||||||
created_at: string;
|
|
||||||
data_period_start?: string;
|
|
||||||
data_period_end?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ModelTrainingStats {
|
|
||||||
total_models: number;
|
|
||||||
active_models: number;
|
|
||||||
last_training_date?: string;
|
|
||||||
avg_training_time_minutes: number;
|
|
||||||
success_rate: number;
|
|
||||||
}
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
// frontend/src/api/utils/error.ts
|
|
||||||
/**
|
|
||||||
* Error Handling Utilities
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { ApiError } from '../client/types';
|
|
||||||
|
|
||||||
export class ApiErrorHandler {
|
|
||||||
static formatError(error: any): string {
|
|
||||||
if (error?.response?.data) {
|
|
||||||
const errorData = error.response.data as ApiError;
|
|
||||||
return errorData.detail || errorData.message || 'An error occurred';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error?.message) {
|
|
||||||
return error.message;
|
|
||||||
}
|
|
||||||
|
|
||||||
return 'An unexpected error occurred';
|
|
||||||
}
|
|
||||||
|
|
||||||
static getErrorCode(error: any): string | undefined {
|
|
||||||
return error?.response?.data?.error_code;
|
|
||||||
}
|
|
||||||
|
|
||||||
static isNetworkError(error: any): boolean {
|
|
||||||
return !error?.response && error?.message?.includes('Network');
|
|
||||||
}
|
|
||||||
|
|
||||||
static isAuthError(error: any): boolean {
|
|
||||||
const status = error?.response?.status;
|
|
||||||
return status === 401 || status === 403;
|
|
||||||
}
|
|
||||||
|
|
||||||
static isValidationError(error: any): boolean {
|
|
||||||
return error?.response?.status === 422;
|
|
||||||
}
|
|
||||||
|
|
||||||
static isServerError(error: any): boolean {
|
|
||||||
const status = error?.response?.status;
|
|
||||||
return status >= 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
static shouldRetry(error: any): boolean {
|
|
||||||
if (this.isNetworkError(error)) return true;
|
|
||||||
if (this.isServerError(error)) return true;
|
|
||||||
const status = error?.response?.status;
|
|
||||||
return status === 408 || status === 429; // Timeout or Rate limited
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
// frontend/src/api/utils/index.ts
|
|
||||||
/**
|
|
||||||
* Main Utils Export
|
|
||||||
*/
|
|
||||||
|
|
||||||
export { ApiErrorHandler } from './error';
|
|
||||||
export { ResponseProcessor } from './response';
|
|
||||||
export { RequestValidator } from './validation';
|
|
||||||
export { DataTransformer } from './transform';
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
// frontend/src/api/utils/response.ts
|
|
||||||
/**
|
|
||||||
* Response Processing Utilities
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { ApiResponse, PaginatedResponse } from '../types';
|
|
||||||
|
|
||||||
export class ResponseProcessor {
|
|
||||||
static extractData<T>(response: ApiResponse<T>): T {
|
|
||||||
return response.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
static extractPaginatedData<T>(response: PaginatedResponse<T>): {
|
|
||||||
data: T[];
|
|
||||||
pagination: PaginatedResponse<T>['pagination'];
|
|
||||||
} {
|
|
||||||
return {
|
|
||||||
data: response.data,
|
|
||||||
pagination: response.pagination,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
static isSuccessResponse(response: ApiResponse): boolean {
|
|
||||||
return response.status === 'success' || response.status === 'ok';
|
|
||||||
}
|
|
||||||
|
|
||||||
static extractMessage(response: ApiResponse): string | undefined {
|
|
||||||
return response.message;
|
|
||||||
}
|
|
||||||
|
|
||||||
static extractMeta(response: ApiResponse): any {
|
|
||||||
return response.meta;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
// frontend/src/api/utils/transform.ts
|
|
||||||
/**
|
|
||||||
* Data Transformation Utilities
|
|
||||||
*/
|
|
||||||
|
|
||||||
export class DataTransformer {
|
|
||||||
static formatDate(date: string | Date): string {
|
|
||||||
const d = new Date(date);
|
|
||||||
return d.toLocaleDateString();
|
|
||||||
}
|
|
||||||
|
|
||||||
static formatDateTime(date: string | Date): string {
|
|
||||||
const d = new Date(date);
|
|
||||||
return d.toLocaleString();
|
|
||||||
}
|
|
||||||
|
|
||||||
static formatCurrency(amount: number, currency = 'EUR'): string {
|
|
||||||
return new Intl.NumberFormat('es-ES', {
|
|
||||||
style: 'currency',
|
|
||||||
currency,
|
|
||||||
}).format(amount);
|
|
||||||
}
|
|
||||||
|
|
||||||
static formatPercentage(value: number, decimals = 1): string {
|
|
||||||
return `${(value * 100).toFixed(decimals)}%`;
|
|
||||||
}
|
|
||||||
|
|
||||||
static formatFileSize(bytes: number): string {
|
|
||||||
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
|
||||||
if (bytes === 0) return '0 Bytes';
|
|
||||||
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
|
||||||
return `${Math.round(bytes / Math.pow(1024, i) * 100) / 100} ${sizes[i]}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
static slugify(text: string): string {
|
|
||||||
return text
|
|
||||||
.toLowerCase()
|
|
||||||
.replace(/[^\w ]+/g, '')
|
|
||||||
.replace(/ +/g, '-');
|
|
||||||
}
|
|
||||||
|
|
||||||
static truncate(text: string, length: number): string {
|
|
||||||
if (text.length <= length) return text;
|
|
||||||
return `${text.substring(0, length)}...`;
|
|
||||||
}
|
|
||||||
|
|
||||||
static camelToKebab(str: string): string {
|
|
||||||
return str.replace(/([a-z0-9]|(?=[A-Z]))([A-Z])/g, '$1-$2').toLowerCase();
|
|
||||||
}
|
|
||||||
|
|
||||||
static kebabToCamel(str: string): string {
|
|
||||||
return str.replace(/-([a-z])/g, (g) => g[1].toUpperCase());
|
|
||||||
}
|
|
||||||
|
|
||||||
static deepClone<T>(obj: T): T {
|
|
||||||
return JSON.parse(JSON.stringify(obj));
|
|
||||||
}
|
|
||||||
|
|
||||||
static removeEmpty(obj: Record<string, any>): Record<string, any> {
|
|
||||||
const cleaned: Record<string, any> = {};
|
|
||||||
|
|
||||||
Object.keys(obj).forEach(key => {
|
|
||||||
if (obj[key] !== null && obj[key] !== undefined && obj[key] !== '') {
|
|
||||||
cleaned[key] = obj[key];
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return cleaned;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
// frontend/src/api/utils/validation.ts
|
|
||||||
/**
|
|
||||||
* Request Validation Utilities
|
|
||||||
*/
|
|
||||||
|
|
||||||
export class RequestValidator {
|
|
||||||
static validateEmail(email: string): boolean {
|
|
||||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
||||||
return emailRegex.test(email);
|
|
||||||
}
|
|
||||||
|
|
||||||
static validatePassword(password: string): {
|
|
||||||
valid: boolean;
|
|
||||||
errors: string[];
|
|
||||||
} {
|
|
||||||
const errors: string[] = [];
|
|
||||||
|
|
||||||
if (password.length < 8) {
|
|
||||||
errors.push('Password must be at least 8 characters long');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!/(?=.*[a-z])/.test(password)) {
|
|
||||||
errors.push('Password must contain at least one lowercase letter');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!/(?=.*[A-Z])/.test(password)) {
|
|
||||||
errors.push('Password must contain at least one uppercase letter');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!/(?=.*\d)/.test(password)) {
|
|
||||||
errors.push('Password must contain at least one number');
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
valid: errors.length === 0,
|
|
||||||
errors,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
static validateFile(file: File, allowedTypes: string[], maxSize: number): {
|
|
||||||
valid: boolean;
|
|
||||||
errors: string[];
|
|
||||||
} {
|
|
||||||
const errors: string[] = [];
|
|
||||||
|
|
||||||
if (!allowedTypes.includes(file.type)) {
|
|
||||||
errors.push(`File type ${file.type} is not allowed`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (file.size > maxSize) {
|
|
||||||
errors.push(`File size exceeds maximum of ${maxSize} bytes`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
valid: errors.length === 0,
|
|
||||||
errors,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
static validatePhoneNumber(phone: string): boolean {
|
|
||||||
// Basic international phone number validation
|
|
||||||
const phoneRegex = /^\+?[1-9]\d{1,14}$/;
|
|
||||||
return phoneRegex.test(phone.replace(/\s/g, ''));
|
|
||||||
}
|
|
||||||
|
|
||||||
static validateRequired(value: any, fieldName: string): string | null {
|
|
||||||
if (value === null || value === undefined || value === '') {
|
|
||||||
return `${fieldName} is required`;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,433 +0,0 @@
|
|||||||
// frontend/src/api/websocket/hooks.ts
|
|
||||||
/**
|
|
||||||
* WebSocket React Hooks
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
|
||||||
import { WebSocketManager } from './manager';
|
|
||||||
import type {
|
|
||||||
WebSocketConfig,
|
|
||||||
WebSocketMessage,
|
|
||||||
WebSocketHandlers,
|
|
||||||
WebSocketStatus,
|
|
||||||
} from './types';
|
|
||||||
|
|
||||||
export const useWebSocket = (config: WebSocketConfig) => {
|
|
||||||
const [status, setStatus] = useState<WebSocketStatus>('disconnected');
|
|
||||||
const [lastMessage, setLastMessage] = useState<WebSocketMessage | null>(null);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const wsManagerRef = useRef<WebSocketManager | null>(null);
|
|
||||||
|
|
||||||
// Initialize WebSocket manager
|
|
||||||
useEffect(() => {
|
|
||||||
wsManagerRef.current = new WebSocketManager(config);
|
|
||||||
|
|
||||||
const handlers: WebSocketHandlers = {
|
|
||||||
onOpen: () => {
|
|
||||||
setStatus('connected');
|
|
||||||
setError(null);
|
|
||||||
},
|
|
||||||
onMessage: (message) => {
|
|
||||||
setLastMessage(message);
|
|
||||||
},
|
|
||||||
onError: (error) => {
|
|
||||||
setError('WebSocket connection error');
|
|
||||||
setStatus('failed');
|
|
||||||
},
|
|
||||||
onClose: () => {
|
|
||||||
setStatus('disconnected');
|
|
||||||
},
|
|
||||||
onReconnect: () => {
|
|
||||||
setStatus('reconnecting');
|
|
||||||
setError(null);
|
|
||||||
},
|
|
||||||
onReconnectFailed: () => {
|
|
||||||
setStatus('failed');
|
|
||||||
setError('Failed to reconnect');
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
wsManagerRef.current.setHandlers(handlers);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
wsManagerRef.current?.disconnect();
|
|
||||||
};
|
|
||||||
}, [config.url]);
|
|
||||||
|
|
||||||
const connect = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
setError(null);
|
|
||||||
await wsManagerRef.current?.connect();
|
|
||||||
} catch (error) {
|
|
||||||
setError(error instanceof Error ? error.message : 'Connection failed');
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const disconnect = useCallback(() => {
|
|
||||||
wsManagerRef.current?.disconnect();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const sendMessage = useCallback((message: Omit<WebSocketMessage, 'timestamp' | 'id'>) => {
|
|
||||||
return wsManagerRef.current?.send(message) ?? false;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const addMessageHandler = useCallback((handler: (message: WebSocketMessage) => void) => {
|
|
||||||
const currentHandlers = wsManagerRef.current?.['handlers'] || {};
|
|
||||||
wsManagerRef.current?.setHandlers({
|
|
||||||
...currentHandlers,
|
|
||||||
onMessage: (message) => {
|
|
||||||
setLastMessage(message);
|
|
||||||
handler(message);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return {
|
|
||||||
status,
|
|
||||||
lastMessage,
|
|
||||||
error,
|
|
||||||
connect,
|
|
||||||
disconnect,
|
|
||||||
sendMessage,
|
|
||||||
addMessageHandler,
|
|
||||||
isConnected: status === 'connected',
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
// Hook for training job updates
|
|
||||||
export const useTrainingWebSocket = (jobId: string, tenantId?: string) => {
|
|
||||||
const [jobUpdates, setJobUpdates] = useState<any[]>([]);
|
|
||||||
const [connectionError, setConnectionError] = useState<string | null>(null);
|
|
||||||
const [isAuthenticationError, setIsAuthenticationError] = useState(false);
|
|
||||||
|
|
||||||
// Get tenant ID reliably with enhanced error handling
|
|
||||||
const actualTenantId = tenantId || (() => {
|
|
||||||
try {
|
|
||||||
// Try multiple sources for tenant ID
|
|
||||||
const sources = [
|
|
||||||
() => localStorage.getItem('current_tenant_id'),
|
|
||||||
() => {
|
|
||||||
const userData = localStorage.getItem('user_data');
|
|
||||||
if (userData) {
|
|
||||||
const parsed = JSON.parse(userData);
|
|
||||||
return parsed.current_tenant_id || parsed.tenant_id;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
},
|
|
||||||
() => {
|
|
||||||
const authData = localStorage.getItem('auth_data');
|
|
||||||
if (authData) {
|
|
||||||
const parsed = JSON.parse(authData);
|
|
||||||
return parsed.tenant_id;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
},
|
|
||||||
() => {
|
|
||||||
const tenantContext = localStorage.getItem('tenant_context');
|
|
||||||
if (tenantContext) {
|
|
||||||
const parsed = JSON.parse(tenantContext);
|
|
||||||
return parsed.current_tenant_id;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const source of sources) {
|
|
||||||
try {
|
|
||||||
const tenantId = source();
|
|
||||||
if (tenantId) return tenantId;
|
|
||||||
} catch (e) {
|
|
||||||
console.warn('Failed to get tenant ID from source:', e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Failed to parse tenant ID from storage:', e);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
})();
|
|
||||||
|
|
||||||
const config = {
|
|
||||||
url: actualTenantId
|
|
||||||
? `ws://localhost:8000/api/v1/ws/tenants/${actualTenantId}/training/jobs/${jobId}/live`
|
|
||||||
: `ws://localhost:8000/api/v1/ws/tenants/unknown/training/jobs/${jobId}/live`,
|
|
||||||
reconnect: true,
|
|
||||||
reconnectInterval: 3000, // Faster reconnection for training
|
|
||||||
maxReconnectAttempts: 20, // More attempts for long training jobs
|
|
||||||
heartbeatInterval: 15000, // Send heartbeat every 15 seconds for training jobs
|
|
||||||
enableLogging: true // Enable logging for debugging
|
|
||||||
};
|
|
||||||
|
|
||||||
const {
|
|
||||||
status,
|
|
||||||
connect,
|
|
||||||
disconnect,
|
|
||||||
addMessageHandler,
|
|
||||||
isConnected,
|
|
||||||
lastMessage,
|
|
||||||
sendMessage
|
|
||||||
} = useWebSocket(config);
|
|
||||||
|
|
||||||
// Enhanced message handler with error handling
|
|
||||||
const handleWebSocketMessage = useCallback((message: any) => {
|
|
||||||
try {
|
|
||||||
// Clear connection error when receiving messages
|
|
||||||
setConnectionError(null);
|
|
||||||
setIsAuthenticationError(false);
|
|
||||||
|
|
||||||
// Handle different message structures
|
|
||||||
let processedMessage = message;
|
|
||||||
|
|
||||||
// If message has nested data, flatten it for easier processing
|
|
||||||
if (message.data && typeof message.data === 'object') {
|
|
||||||
processedMessage = {
|
|
||||||
...message,
|
|
||||||
// Merge data properties to root level for backward compatibility
|
|
||||||
...message.data,
|
|
||||||
// Preserve original structure
|
|
||||||
_originalData: message.data
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle special message types
|
|
||||||
if (message.type === 'connection_established') {
|
|
||||||
console.log('WebSocket training connection established:', message);
|
|
||||||
setJobUpdates(prev => [{
|
|
||||||
type: 'connection_established',
|
|
||||||
message: 'Connected to training service',
|
|
||||||
timestamp: Date.now()
|
|
||||||
}, ...prev.slice(0, 49)]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle keepalive messages (don't show to user, just for connection health)
|
|
||||||
if (message.type === 'pong' || message.type === 'heartbeat') {
|
|
||||||
console.debug('Training WebSocket keepalive received:', message.type);
|
|
||||||
return; // Don't add to jobUpdates
|
|
||||||
}
|
|
||||||
|
|
||||||
if (message.type === 'authentication_error' || message.type === 'authorization_error') {
|
|
||||||
console.error('WebSocket auth/authorization error:', message);
|
|
||||||
setIsAuthenticationError(true);
|
|
||||||
setConnectionError(message.message || 'Authentication/authorization failed - please refresh and try again');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (message.type === 'connection_error') {
|
|
||||||
console.error('WebSocket connection error:', message);
|
|
||||||
setConnectionError(message.message || 'Connection error');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (message.type === 'connection_timeout') {
|
|
||||||
console.warn('WebSocket connection timeout:', message);
|
|
||||||
// Don't set as error, just log - connection will retry
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (message.type === 'job_not_found') {
|
|
||||||
console.error('Training job not found:', message);
|
|
||||||
setConnectionError('Training job not found. Please restart the training process.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Comprehensive message type handling
|
|
||||||
const trainingMessageTypes = [
|
|
||||||
'progress', 'training_progress',
|
|
||||||
'completed', 'training_completed',
|
|
||||||
'failed', 'training_failed',
|
|
||||||
'error', 'training_error',
|
|
||||||
'started', 'training_started',
|
|
||||||
'heartbeat', 'initial_status',
|
|
||||||
'status_update'
|
|
||||||
];
|
|
||||||
|
|
||||||
if (trainingMessageTypes.includes(message.type)) {
|
|
||||||
// Add to updates array with processed message
|
|
||||||
setJobUpdates(prev => {
|
|
||||||
const newUpdates = [processedMessage, ...prev.slice(0, 49)]; // Keep last 50 messages
|
|
||||||
return newUpdates;
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// Still add to updates for debugging purposes
|
|
||||||
console.log('Received unknown message type:', message.type, message);
|
|
||||||
setJobUpdates(prev => [processedMessage, ...prev.slice(0, 49)]);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error processing WebSocket message:', error, message);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Set up message handler when hook initializes
|
|
||||||
useEffect(() => {
|
|
||||||
addMessageHandler(handleWebSocketMessage);
|
|
||||||
}, [addMessageHandler, handleWebSocketMessage]);
|
|
||||||
|
|
||||||
// Enhanced dual ping system for training jobs - prevent disconnection during long training
|
|
||||||
useEffect(() => {
|
|
||||||
if (isConnected) {
|
|
||||||
// Primary ping system using JSON messages with training info
|
|
||||||
const keepaliveInterval = setInterval(() => {
|
|
||||||
const success = sendMessage({
|
|
||||||
type: 'training_keepalive',
|
|
||||||
data: {
|
|
||||||
timestamp: Date.now(),
|
|
||||||
job_id: jobId,
|
|
||||||
tenant_id: actualTenantId,
|
|
||||||
status: 'active'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!success) {
|
|
||||||
console.warn('Training keepalive failed - connection may be lost');
|
|
||||||
}
|
|
||||||
}, 10000); // Every 10 seconds for training jobs
|
|
||||||
|
|
||||||
// Secondary simple text ping system (more lightweight)
|
|
||||||
const simplePingInterval = setInterval(() => {
|
|
||||||
// Send a simple text ping to keep connection alive
|
|
||||||
const success = sendMessage({
|
|
||||||
type: 'ping',
|
|
||||||
data: {
|
|
||||||
timestamp: Date.now(),
|
|
||||||
source: 'training_client'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!success) {
|
|
||||||
console.warn('Simple training ping failed');
|
|
||||||
}
|
|
||||||
}, 15000); // Every 15 seconds
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
clearInterval(keepaliveInterval);
|
|
||||||
clearInterval(simplePingInterval);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}, [isConnected, sendMessage, jobId, actualTenantId]);
|
|
||||||
|
|
||||||
// Define refresh connection function
|
|
||||||
const refreshConnection = useCallback(() => {
|
|
||||||
setConnectionError(null);
|
|
||||||
setIsAuthenticationError(false);
|
|
||||||
disconnect();
|
|
||||||
setTimeout(() => {
|
|
||||||
connect();
|
|
||||||
}, 1000);
|
|
||||||
}, [connect, disconnect]);
|
|
||||||
|
|
||||||
// Enhanced connection monitoring and auto-recovery for training jobs
|
|
||||||
useEffect(() => {
|
|
||||||
if (actualTenantId && jobId !== 'pending') {
|
|
||||||
const healthCheckInterval = setInterval(() => {
|
|
||||||
// If we should be connected but aren't, try to reconnect
|
|
||||||
if (status === 'disconnected' && !connectionError) {
|
|
||||||
console.log('WebSocket health check: reconnecting disconnected training socket');
|
|
||||||
connect();
|
|
||||||
}
|
|
||||||
|
|
||||||
// More aggressive stale connection detection for training jobs
|
|
||||||
const lastUpdate = jobUpdates.length > 0 ? jobUpdates[0] : null;
|
|
||||||
if (lastUpdate && status === 'connected') {
|
|
||||||
const timeSinceLastMessage = Date.now() - (lastUpdate.timestamp || 0);
|
|
||||||
if (timeSinceLastMessage > 45000) { // 45 seconds without messages during training
|
|
||||||
console.log('WebSocket health check: connection appears stale, refreshing');
|
|
||||||
refreshConnection();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// If connection is in a failed state for too long, force reconnect
|
|
||||||
if (status === 'failed' && !isAuthenticationError) {
|
|
||||||
console.log('WebSocket health check: recovering from failed state');
|
|
||||||
setTimeout(() => connect(), 2000);
|
|
||||||
}
|
|
||||||
}, 12000); // Check every 12 seconds for training jobs
|
|
||||||
|
|
||||||
return () => clearInterval(healthCheckInterval);
|
|
||||||
}
|
|
||||||
}, [actualTenantId, jobId, status, connectionError, connect, refreshConnection, jobUpdates, isAuthenticationError]);
|
|
||||||
|
|
||||||
// Enhanced connection setup - request current status when connecting
|
|
||||||
useEffect(() => {
|
|
||||||
if (isConnected && jobId !== 'pending') {
|
|
||||||
// Wait a moment for connection to stabilize, then request current status
|
|
||||||
const statusRequestTimer = setTimeout(() => {
|
|
||||||
console.log('Requesting current training status after connection');
|
|
||||||
sendMessage({
|
|
||||||
type: 'get_status',
|
|
||||||
data: {
|
|
||||||
job_id: jobId,
|
|
||||||
tenant_id: actualTenantId
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}, 2000);
|
|
||||||
|
|
||||||
return () => clearTimeout(statusRequestTimer);
|
|
||||||
}
|
|
||||||
}, [isConnected, jobId, actualTenantId, sendMessage]);
|
|
||||||
|
|
||||||
return {
|
|
||||||
status,
|
|
||||||
jobUpdates,
|
|
||||||
connect,
|
|
||||||
disconnect,
|
|
||||||
isConnected,
|
|
||||||
lastMessage,
|
|
||||||
tenantId: actualTenantId,
|
|
||||||
wsUrl: config.url,
|
|
||||||
connectionError,
|
|
||||||
isAuthenticationError,
|
|
||||||
// Enhanced refresh function with status request
|
|
||||||
refreshConnection,
|
|
||||||
// Force retry with new authentication
|
|
||||||
retryWithAuth: useCallback(() => {
|
|
||||||
setConnectionError(null);
|
|
||||||
setIsAuthenticationError(false);
|
|
||||||
// Clear any cached auth data that might be stale
|
|
||||||
disconnect();
|
|
||||||
setTimeout(() => {
|
|
||||||
connect();
|
|
||||||
}, 2000);
|
|
||||||
}, [connect, disconnect]),
|
|
||||||
// Manual status request function
|
|
||||||
requestStatus: useCallback(() => {
|
|
||||||
if (isConnected && jobId !== 'pending') {
|
|
||||||
return sendMessage({
|
|
||||||
type: 'get_status',
|
|
||||||
data: {
|
|
||||||
job_id: jobId,
|
|
||||||
tenant_id: actualTenantId
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}, [isConnected, jobId, actualTenantId, sendMessage])
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
// Hook for forecast alerts
|
|
||||||
export const useForecastWebSocket = (tenantId: string) => {
|
|
||||||
const config: WebSocketConfig = {
|
|
||||||
url: `ws://localhost:8000/api/v1/ws/forecasts/${tenantId}`,
|
|
||||||
reconnect: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
const [alerts, setAlerts] = useState<any[]>([]);
|
|
||||||
|
|
||||||
const { status, connect, disconnect, addMessageHandler, isConnected } = useWebSocket(config);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
addMessageHandler((message) => {
|
|
||||||
if (message.type === 'forecast_alert') {
|
|
||||||
setAlerts(prev => [message.data, ...prev]);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}, [addMessageHandler]);
|
|
||||||
|
|
||||||
return {
|
|
||||||
status,
|
|
||||||
alerts,
|
|
||||||
connect,
|
|
||||||
disconnect,
|
|
||||||
isConnected,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
// frontend/src/api/websocket/index.ts
|
|
||||||
/**
|
|
||||||
* Main WebSocket Export
|
|
||||||
*/
|
|
||||||
|
|
||||||
export { WebSocketManager } from './manager';
|
|
||||||
export {
|
|
||||||
useWebSocket,
|
|
||||||
useTrainingWebSocket,
|
|
||||||
useForecastWebSocket,
|
|
||||||
} from './hooks';
|
|
||||||
export type {
|
|
||||||
WebSocketConfig,
|
|
||||||
WebSocketMessage,
|
|
||||||
WebSocketHandlers,
|
|
||||||
WebSocketStatus,
|
|
||||||
WebSocketMetrics,
|
|
||||||
} from './types';
|
|
||||||
@@ -1,274 +0,0 @@
|
|||||||
// frontend/src/api/websocket/manager.ts
|
|
||||||
/**
|
|
||||||
* WebSocket Manager
|
|
||||||
* Handles WebSocket connections with auto-reconnection and heartbeat
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { apiConfig } from '../client/config';
|
|
||||||
import type {
|
|
||||||
WebSocketConfig,
|
|
||||||
WebSocketMessage,
|
|
||||||
WebSocketHandlers,
|
|
||||||
WebSocketStatus,
|
|
||||||
WebSocketMetrics,
|
|
||||||
} from './types';
|
|
||||||
|
|
||||||
export class WebSocketManager {
|
|
||||||
private ws: WebSocket | null = null;
|
|
||||||
private config: WebSocketConfig;
|
|
||||||
private handlers: WebSocketHandlers = {};
|
|
||||||
private status: WebSocketStatus = 'disconnected';
|
|
||||||
private reconnectTimer: NodeJS.Timeout | null = null;
|
|
||||||
private heartbeatTimer: NodeJS.Timeout | null = null;
|
|
||||||
private reconnectAttempts = 0;
|
|
||||||
private metrics: WebSocketMetrics = {
|
|
||||||
reconnectAttempts: 0,
|
|
||||||
messagesReceived: 0,
|
|
||||||
messagesSent: 0,
|
|
||||||
lastActivity: new Date(),
|
|
||||||
};
|
|
||||||
|
|
||||||
constructor(config: WebSocketConfig) {
|
|
||||||
this.config = {
|
|
||||||
reconnect: true,
|
|
||||||
reconnectInterval: 5000,
|
|
||||||
maxReconnectAttempts: 10,
|
|
||||||
heartbeatInterval: 30000,
|
|
||||||
enableLogging: apiConfig.enableLogging,
|
|
||||||
...config,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Connect to WebSocket
|
|
||||||
*/
|
|
||||||
connect(): Promise<void> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
try {
|
|
||||||
this.status = 'connecting';
|
|
||||||
this.log('Connecting to WebSocket:', this.config.url);
|
|
||||||
|
|
||||||
// Add authentication token to URL if available
|
|
||||||
const token = localStorage.getItem('auth_token');
|
|
||||||
const wsUrl = token
|
|
||||||
? `${this.config.url}?token=${encodeURIComponent(token)}`
|
|
||||||
: this.config.url;
|
|
||||||
|
|
||||||
this.ws = new WebSocket(wsUrl, this.config.protocols);
|
|
||||||
|
|
||||||
this.ws.onopen = (event) => {
|
|
||||||
this.status = 'connected';
|
|
||||||
this.reconnectAttempts = 0;
|
|
||||||
this.metrics.connectionTime = Date.now();
|
|
||||||
this.metrics.lastActivity = new Date();
|
|
||||||
|
|
||||||
this.log('WebSocket connected');
|
|
||||||
this.startHeartbeat();
|
|
||||||
|
|
||||||
this.handlers.onOpen?.(event);
|
|
||||||
resolve();
|
|
||||||
};
|
|
||||||
|
|
||||||
this.ws.onmessage = (event) => {
|
|
||||||
this.metrics.messagesReceived++;
|
|
||||||
this.metrics.lastActivity = new Date();
|
|
||||||
|
|
||||||
try {
|
|
||||||
const message: WebSocketMessage = JSON.parse(event.data);
|
|
||||||
this.log('WebSocket message received:', message.type);
|
|
||||||
this.handlers.onMessage?.(message);
|
|
||||||
} catch (error) {
|
|
||||||
this.log('Failed to parse WebSocket message:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
this.ws.onerror = (error) => {
|
|
||||||
this.log('WebSocket error:', error);
|
|
||||||
this.handlers.onError?.(error);
|
|
||||||
|
|
||||||
if (this.status === 'connecting') {
|
|
||||||
reject(new Error('WebSocket connection failed'));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
this.ws.onclose = (event) => {
|
|
||||||
this.log('WebSocket closed:', event.code, event.reason);
|
|
||||||
this.status = 'disconnected';
|
|
||||||
this.stopHeartbeat();
|
|
||||||
|
|
||||||
this.handlers.onClose?.(event);
|
|
||||||
|
|
||||||
// Auto-reconnect if enabled and not manually closed
|
|
||||||
// Don't reconnect on authorization failures or job not found (1008) with specific reasons
|
|
||||||
const isAuthorizationError = event.code === 1008 &&
|
|
||||||
(event.reason === 'Authentication failed' || event.reason === 'Authorization failed');
|
|
||||||
const isJobNotFound = event.code === 1008 && event.reason === 'Job not found';
|
|
||||||
|
|
||||||
if (this.config.reconnect && event.code !== 1000 && !isAuthorizationError && !isJobNotFound) {
|
|
||||||
this.scheduleReconnect();
|
|
||||||
} else if (isAuthorizationError || isJobNotFound) {
|
|
||||||
this.log('Connection failed - stopping reconnection attempts:', event.reason);
|
|
||||||
this.status = 'failed';
|
|
||||||
this.handlers.onReconnectFailed?.();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
this.status = 'failed';
|
|
||||||
reject(error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Disconnect from WebSocket
|
|
||||||
*/
|
|
||||||
disconnect(): void {
|
|
||||||
this.config.reconnect = false; // Disable auto-reconnect
|
|
||||||
this.clearReconnectTimer();
|
|
||||||
this.stopHeartbeat();
|
|
||||||
|
|
||||||
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
|
||||||
this.ws.close(1000, 'Manual disconnect');
|
|
||||||
}
|
|
||||||
|
|
||||||
this.ws = null;
|
|
||||||
this.status = 'disconnected';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Send message through WebSocket
|
|
||||||
*/
|
|
||||||
send(message: Omit<WebSocketMessage, 'timestamp' | 'id'>): boolean {
|
|
||||||
if (!this.isConnected()) {
|
|
||||||
this.log('Cannot send message: WebSocket not connected');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const fullMessage: WebSocketMessage = {
|
|
||||||
...message,
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
id: this.generateMessageId(),
|
|
||||||
};
|
|
||||||
|
|
||||||
this.ws!.send(JSON.stringify(fullMessage));
|
|
||||||
this.metrics.messagesSent++;
|
|
||||||
this.metrics.lastActivity = new Date();
|
|
||||||
|
|
||||||
this.log('WebSocket message sent:', message.type);
|
|
||||||
return true;
|
|
||||||
} catch (error) {
|
|
||||||
this.log('Failed to send WebSocket message:', error);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set event handlers
|
|
||||||
*/
|
|
||||||
setHandlers(handlers: WebSocketHandlers): void {
|
|
||||||
this.handlers = { ...this.handlers, ...handlers };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get connection status
|
|
||||||
*/
|
|
||||||
getStatus(): WebSocketStatus {
|
|
||||||
return this.status;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if connected
|
|
||||||
*/
|
|
||||||
isConnected(): boolean {
|
|
||||||
return this.ws?.readyState === WebSocket.OPEN;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get connection metrics
|
|
||||||
*/
|
|
||||||
getMetrics(): WebSocketMetrics {
|
|
||||||
return { ...this.metrics };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Schedule reconnection attempt
|
|
||||||
*/
|
|
||||||
private scheduleReconnect(): void {
|
|
||||||
if (this.reconnectAttempts >= this.config.maxReconnectAttempts!) {
|
|
||||||
this.status = 'failed';
|
|
||||||
this.log('Max reconnection attempts reached');
|
|
||||||
this.handlers.onReconnectFailed?.();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.status = 'reconnecting';
|
|
||||||
this.reconnectAttempts++;
|
|
||||||
this.metrics.reconnectAttempts++;
|
|
||||||
|
|
||||||
this.log(`Scheduling reconnection attempt ${this.reconnectAttempts}/${this.config.maxReconnectAttempts}`);
|
|
||||||
|
|
||||||
this.reconnectTimer = setTimeout(async () => {
|
|
||||||
try {
|
|
||||||
this.handlers.onReconnect?.();
|
|
||||||
await this.connect();
|
|
||||||
} catch (error) {
|
|
||||||
this.log('Reconnection failed:', error);
|
|
||||||
this.scheduleReconnect();
|
|
||||||
}
|
|
||||||
}, this.config.reconnectInterval);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Clear reconnection timer
|
|
||||||
*/
|
|
||||||
private clearReconnectTimer(): void {
|
|
||||||
if (this.reconnectTimer) {
|
|
||||||
clearTimeout(this.reconnectTimer);
|
|
||||||
this.reconnectTimer = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Start heartbeat mechanism
|
|
||||||
*/
|
|
||||||
private startHeartbeat(): void {
|
|
||||||
if (!this.config.heartbeatInterval) return;
|
|
||||||
|
|
||||||
this.heartbeatTimer = setInterval(() => {
|
|
||||||
if (this.isConnected()) {
|
|
||||||
this.send({
|
|
||||||
type: 'ping',
|
|
||||||
data: { timestamp: Date.now() },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, this.config.heartbeatInterval);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Stop heartbeat mechanism
|
|
||||||
*/
|
|
||||||
private stopHeartbeat(): void {
|
|
||||||
if (this.heartbeatTimer) {
|
|
||||||
clearInterval(this.heartbeatTimer);
|
|
||||||
this.heartbeatTimer = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate unique message ID
|
|
||||||
*/
|
|
||||||
private generateMessageId(): string {
|
|
||||||
return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Log message if logging enabled
|
|
||||||
*/
|
|
||||||
private log(...args: any[]): void {
|
|
||||||
if (this.config.enableLogging) {
|
|
||||||
console.log('[WebSocket]', ...args);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
// frontend/src/api/websocket/types.ts
|
|
||||||
/**
|
|
||||||
* WebSocket Types
|
|
||||||
*/
|
|
||||||
|
|
||||||
export interface WebSocketConfig {
|
|
||||||
url: string;
|
|
||||||
protocols?: string[];
|
|
||||||
reconnect?: boolean;
|
|
||||||
reconnectInterval?: number;
|
|
||||||
maxReconnectAttempts?: number;
|
|
||||||
heartbeatInterval?: number;
|
|
||||||
enableLogging?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface WebSocketMessage {
|
|
||||||
type: string;
|
|
||||||
data: any;
|
|
||||||
timestamp: string;
|
|
||||||
id?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface WebSocketHandlers {
|
|
||||||
onOpen?: (event: Event) => void;
|
|
||||||
onMessage?: (message: WebSocketMessage) => void;
|
|
||||||
onError?: (error: Event) => void;
|
|
||||||
onClose?: (event: CloseEvent) => void;
|
|
||||||
onReconnect?: () => void;
|
|
||||||
onReconnectFailed?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type WebSocketStatus =
|
|
||||||
| 'connecting'
|
|
||||||
| 'connected'
|
|
||||||
| 'disconnected'
|
|
||||||
| 'reconnecting'
|
|
||||||
| 'failed';
|
|
||||||
|
|
||||||
export interface WebSocketMetrics {
|
|
||||||
connectionTime?: number;
|
|
||||||
reconnectAttempts: number;
|
|
||||||
messagesReceived: number;
|
|
||||||
messagesSent: number;
|
|
||||||
lastActivity: Date;
|
|
||||||
}
|
|
||||||
@@ -1,387 +0,0 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
|
||||||
import {
|
|
||||||
Brain, Cpu, Database, TrendingUp, CheckCircle, AlertCircle,
|
|
||||||
Clock, Zap, Target, BarChart3, Loader
|
|
||||||
} from 'lucide-react';
|
|
||||||
|
|
||||||
interface TrainingProgressProps {
|
|
||||||
progress: {
|
|
||||||
progress: number;
|
|
||||||
status: string;
|
|
||||||
currentStep: string;
|
|
||||||
productsCompleted: number;
|
|
||||||
productsTotal: number;
|
|
||||||
estimatedTimeRemaining: number;
|
|
||||||
error?: string;
|
|
||||||
};
|
|
||||||
onTimeout?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Map backend steps to user-friendly information
|
|
||||||
const STEP_INFO_MAP = {
|
|
||||||
'data_validation': {
|
|
||||||
title: 'Validando tus datos',
|
|
||||||
description: 'Verificamos la calidad y completitud de tu información histórica',
|
|
||||||
tip: '💡 Datos más completos = predicciones más precisas',
|
|
||||||
icon: Database,
|
|
||||||
color: 'blue'
|
|
||||||
},
|
|
||||||
'feature_engineering': {
|
|
||||||
title: 'Creando características predictivas',
|
|
||||||
description: 'Identificamos patrones estacionales y tendencias en tus ventas',
|
|
||||||
tip: '💡 Tu modelo detectará automáticamente picos de demanda',
|
|
||||||
icon: TrendingUp,
|
|
||||||
color: 'indigo'
|
|
||||||
},
|
|
||||||
'model_training': {
|
|
||||||
title: 'Entrenando modelo de IA',
|
|
||||||
description: 'Creamos tu modelo personalizado usando algoritmos avanzados',
|
|
||||||
tip: '💡 Este proceso optimiza las predicciones para tu negocio específico',
|
|
||||||
icon: Brain,
|
|
||||||
color: 'purple'
|
|
||||||
},
|
|
||||||
'model_validation': {
|
|
||||||
title: 'Validando precisión',
|
|
||||||
description: 'Verificamos que el modelo genere predicciones confiables',
|
|
||||||
tip: '💡 Garantizamos que las predicciones sean útiles para tu toma de decisiones',
|
|
||||||
icon: Target,
|
|
||||||
color: 'green'
|
|
||||||
},
|
|
||||||
// Handle any unmapped steps
|
|
||||||
'default': {
|
|
||||||
title: 'Procesando...',
|
|
||||||
description: 'Procesando tus datos para crear el modelo de predicción',
|
|
||||||
tip: '💡 Cada paso nos acerca a predicciones más precisas',
|
|
||||||
icon: Cpu,
|
|
||||||
color: 'gray'
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const EXPECTED_BENEFITS = [
|
|
||||||
{
|
|
||||||
icon: BarChart3,
|
|
||||||
title: 'Predicciones Precisas',
|
|
||||||
description: 'Conoce exactamente cuánto vender cada día'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: Zap,
|
|
||||||
title: 'Optimización Automática',
|
|
||||||
description: 'Reduce desperdicios y maximiza ganancias'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: TrendingUp,
|
|
||||||
title: 'Detección de Tendencias',
|
|
||||||
description: 'Identifica patrones estacionales y eventos especiales'
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function EnhancedTrainingProgress({ progress, onTimeout }: TrainingProgressProps) {
|
|
||||||
const [showTimeoutWarning, setShowTimeoutWarning] = useState(false);
|
|
||||||
const [startTime] = useState(Date.now());
|
|
||||||
|
|
||||||
// Auto-show timeout warning after 8 minutes (480,000ms)
|
|
||||||
useEffect(() => {
|
|
||||||
const timeoutTimer = setTimeout(() => {
|
|
||||||
if (progress.status === 'running' && progress.progress < 100) {
|
|
||||||
setShowTimeoutWarning(true);
|
|
||||||
}
|
|
||||||
}, 480000); // 8 minutes
|
|
||||||
|
|
||||||
return () => clearTimeout(timeoutTimer);
|
|
||||||
}, [progress.status, progress.progress]);
|
|
||||||
|
|
||||||
const getCurrentStepInfo = () => {
|
|
||||||
// Try to match the current step from backend
|
|
||||||
const stepKey = progress.currentStep?.toLowerCase().replace(/\s+/g, '_');
|
|
||||||
return STEP_INFO_MAP[stepKey] || STEP_INFO_MAP['default'];
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatTime = (seconds: number): string => {
|
|
||||||
if (!seconds || seconds <= 0) return '0m 0s';
|
|
||||||
const minutes = Math.floor(seconds / 60);
|
|
||||||
const remainingSeconds = seconds % 60;
|
|
||||||
return `${minutes}m ${remainingSeconds}s`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getProgressSteps = () => {
|
|
||||||
// Create progress steps based on current progress percentage
|
|
||||||
const steps = [
|
|
||||||
{ id: 'data_validation', threshold: 25, name: 'Validación' },
|
|
||||||
{ id: 'feature_engineering', threshold: 45, name: 'Características' },
|
|
||||||
{ id: 'model_training', threshold: 85, name: 'Entrenamiento' },
|
|
||||||
{ id: 'model_validation', threshold: 100, name: 'Validación' }
|
|
||||||
];
|
|
||||||
|
|
||||||
return steps.map(step => ({
|
|
||||||
...step,
|
|
||||||
completed: progress.progress >= step.threshold,
|
|
||||||
current: progress.progress >= (step.threshold - 25) && progress.progress < step.threshold
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleContinueToDashboard = () => {
|
|
||||||
setShowTimeoutWarning(false);
|
|
||||||
if (onTimeout) {
|
|
||||||
onTimeout();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleKeepWaiting = () => {
|
|
||||||
setShowTimeoutWarning(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const currentStepInfo = getCurrentStepInfo();
|
|
||||||
const progressSteps = getProgressSteps();
|
|
||||||
|
|
||||||
// Handle error state
|
|
||||||
if (progress.status === 'failed' || progress.error) {
|
|
||||||
return (
|
|
||||||
<div className="max-w-4xl mx-auto">
|
|
||||||
<div className="text-center mb-8">
|
|
||||||
<div className="inline-flex items-center justify-center w-20 h-20 bg-red-100 rounded-full mb-4">
|
|
||||||
<AlertCircle className="w-10 h-10 text-red-600" />
|
|
||||||
</div>
|
|
||||||
<h2 className="text-3xl font-bold text-gray-900 mb-2">
|
|
||||||
Error en el Entrenamiento
|
|
||||||
</h2>
|
|
||||||
<p className="text-lg text-gray-600 max-w-2xl mx-auto">
|
|
||||||
Ha ocurrido un problema durante el entrenamiento. Nuestro equipo ha sido notificado.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-white rounded-2xl shadow-soft p-8 mb-8">
|
|
||||||
<div className="bg-red-50 border border-red-200 rounded-xl p-6">
|
|
||||||
<div className="flex items-start space-x-4">
|
|
||||||
<AlertCircle className="w-6 h-6 text-red-600 flex-shrink-0 mt-1" />
|
|
||||||
<div>
|
|
||||||
<h3 className="text-lg font-semibold text-red-800 mb-2">
|
|
||||||
Detalles del Error
|
|
||||||
</h3>
|
|
||||||
<p className="text-red-700">
|
|
||||||
{progress.error || 'Error desconocido durante el entrenamiento'}
|
|
||||||
</p>
|
|
||||||
<div className="mt-4 text-sm text-red-600">
|
|
||||||
<p>• Puedes intentar el entrenamiento nuevamente</p>
|
|
||||||
<p>• Verifica que tus datos históricos estén completos</p>
|
|
||||||
<p>• Contacta soporte si el problema persiste</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-6 text-center">
|
|
||||||
<button
|
|
||||||
onClick={() => window.location.reload()}
|
|
||||||
className="bg-primary-500 text-white px-6 py-3 rounded-xl font-medium hover:bg-primary-600 transition-colors"
|
|
||||||
>
|
|
||||||
Intentar Nuevamente
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="max-w-4xl mx-auto">
|
|
||||||
{/* Header */}
|
|
||||||
<div className="text-center mb-8">
|
|
||||||
<div className="inline-flex items-center justify-center w-20 h-20 bg-primary-500 rounded-full mb-4">
|
|
||||||
<Brain className="w-10 h-10 text-white animate-pulse" />
|
|
||||||
</div>
|
|
||||||
<h2 className="text-3xl font-bold text-gray-900 mb-2">
|
|
||||||
🧠 Entrenando tu modelo de predicción
|
|
||||||
</h2>
|
|
||||||
<p className="text-lg text-gray-600 max-w-2xl mx-auto">
|
|
||||||
Estamos procesando tus datos históricos para crear predicciones personalizadas
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Main Progress Section */}
|
|
||||||
<div className="bg-white rounded-2xl shadow-soft p-8 mb-8">
|
|
||||||
{/* Overall Progress Bar */}
|
|
||||||
<div className="mb-8">
|
|
||||||
<div className="flex justify-between items-center mb-3">
|
|
||||||
<span className="text-sm font-medium text-gray-700">Progreso General</span>
|
|
||||||
<span className="text-sm font-bold text-blue-600">{progress.progress}%</span>
|
|
||||||
</div>
|
|
||||||
<div className="w-full bg-gray-200 rounded-full h-4 overflow-hidden">
|
|
||||||
<div
|
|
||||||
className="bg-primary-500 h-4 rounded-full transition-all duration-1000 ease-out relative"
|
|
||||||
style={{ width: `${progress.progress}%` }}
|
|
||||||
>
|
|
||||||
<div className="absolute inset-0 opacity-20 animate-pulse">
|
|
||||||
<div className="h-full bg-white rounded-full"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Current Step Info */}
|
|
||||||
<div className={`bg-${currentStepInfo.color}-50 border border-${currentStepInfo.color}-200 rounded-xl p-6 mb-6`}>
|
|
||||||
<div className="flex items-start space-x-4">
|
|
||||||
<div className="flex-shrink-0">
|
|
||||||
<div className={`w-12 h-12 bg-primary-500 rounded-full flex items-center justify-center`}>
|
|
||||||
<currentStepInfo.icon className="w-6 h-6 text-white" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex-1">
|
|
||||||
<h3 className="text-xl font-semibold text-gray-900 mb-2">
|
|
||||||
{currentStepInfo.title}
|
|
||||||
</h3>
|
|
||||||
<p className="text-gray-700 mb-3">
|
|
||||||
{currentStepInfo.description}
|
|
||||||
</p>
|
|
||||||
<div className={`bg-primary-50 border-l-4 border-primary-500 p-3 rounded-r-xl`}>
|
|
||||||
<p className={`text-sm font-medium text-primary-700`}>
|
|
||||||
{currentStepInfo.tip}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Step Progress Indicators */}
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-8">
|
|
||||||
{progressSteps.map((step, index) => (
|
|
||||||
<div
|
|
||||||
key={step.id}
|
|
||||||
className={`p-4 rounded-xl border-2 transition-all duration-300 ${
|
|
||||||
step.completed
|
|
||||||
? 'bg-green-50 border-green-200'
|
|
||||||
: step.current
|
|
||||||
? 'bg-primary-50 border-primary-300 shadow-soft'
|
|
||||||
: 'bg-gray-50 border-gray-200'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className="flex items-center mb-2">
|
|
||||||
{step.completed ? (
|
|
||||||
<CheckCircle className="w-5 h-5 text-green-600 mr-2" />
|
|
||||||
) : step.current ? (
|
|
||||||
<div className="w-5 h-5 border-2 border-primary-500 border-t-transparent rounded-full animate-spin mr-2"></div>
|
|
||||||
) : (
|
|
||||||
<div className="w-5 h-5 border-2 border-gray-300 rounded-full mr-2"></div>
|
|
||||||
)}
|
|
||||||
<span className={`text-sm font-medium ${
|
|
||||||
step.completed ? 'text-green-800' : step.current ? 'text-primary-700' : 'text-gray-600'
|
|
||||||
}`}>
|
|
||||||
{step.name}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Enhanced Stats Grid */}
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
|
||||||
<div className="text-center p-4 bg-gray-50 rounded-xl">
|
|
||||||
<div className="flex items-center justify-center mb-2">
|
|
||||||
<Cpu className="w-5 h-5 text-gray-600 mr-2" />
|
|
||||||
<span className="text-sm font-medium text-gray-700">Productos Procesados</span>
|
|
||||||
</div>
|
|
||||||
<div className="text-2xl font-bold text-gray-900">
|
|
||||||
{progress.productsCompleted}/{progress.productsTotal || 'N/A'}
|
|
||||||
</div>
|
|
||||||
{progress.productsTotal > 0 && (
|
|
||||||
<div className="w-full bg-gray-200 rounded-full h-2 mt-2">
|
|
||||||
<div
|
|
||||||
className="bg-primary-500 h-2 rounded-full transition-all duration-500"
|
|
||||||
style={{ width: `${(progress.productsCompleted / progress.productsTotal) * 100}%` }}
|
|
||||||
></div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="text-center p-4 bg-gray-50 rounded-xl">
|
|
||||||
<div className="flex items-center justify-center mb-2">
|
|
||||||
<Clock className="w-5 h-5 text-gray-600 mr-2" />
|
|
||||||
<span className="text-sm font-medium text-gray-700">Tiempo Restante</span>
|
|
||||||
</div>
|
|
||||||
<div className="text-2xl font-bold text-gray-900">
|
|
||||||
{progress.estimatedTimeRemaining
|
|
||||||
? formatTime(progress.estimatedTimeRemaining * 60) // Convert minutes to seconds
|
|
||||||
: 'Calculando...'
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="text-center p-4 bg-gray-50 rounded-xl">
|
|
||||||
<div className="flex items-center justify-center mb-2">
|
|
||||||
<Target className="w-5 h-5 text-gray-600 mr-2" />
|
|
||||||
<span className="text-sm font-medium text-gray-700">Precisión Esperada</span>
|
|
||||||
</div>
|
|
||||||
<div className="text-2xl font-bold text-green-600">
|
|
||||||
~85%
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Status Indicator */}
|
|
||||||
<div className="mt-6 flex items-center justify-center">
|
|
||||||
<div className="flex items-center space-x-2 text-sm text-gray-600">
|
|
||||||
<Loader className="w-4 h-4 animate-spin" />
|
|
||||||
<span>Estado: {progress.status === 'running' ? 'Entrenando' : progress.status}</span>
|
|
||||||
<span>•</span>
|
|
||||||
<span>Paso actual: {progress.currentStep || 'Procesando...'}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Expected Benefits - Only show if progress < 80% to keep user engaged */}
|
|
||||||
{progress.progress < 80 && (
|
|
||||||
<div className="bg-white rounded-2xl shadow-soft p-8">
|
|
||||||
<h3 className="text-2xl font-bold text-gray-900 mb-6 text-center">
|
|
||||||
Lo que podrás hacer una vez completado
|
|
||||||
</h3>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
|
||||||
{EXPECTED_BENEFITS.map((benefit, index) => (
|
|
||||||
<div key={index} className="text-center p-6 bg-gradient-to-br from-primary-50 to-blue-50 rounded-xl">
|
|
||||||
<div className="inline-flex items-center justify-center w-12 h-12 bg-primary-500 rounded-full mb-4">
|
|
||||||
<benefit.icon className="w-6 h-6 text-white" />
|
|
||||||
</div>
|
|
||||||
<h4 className="text-lg font-semibold text-gray-900 mb-2">
|
|
||||||
{benefit.title}
|
|
||||||
</h4>
|
|
||||||
<p className="text-gray-600">
|
|
||||||
{benefit.description}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Timeout Warning Modal */}
|
|
||||||
{showTimeoutWarning && (
|
|
||||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
|
||||||
<div className="bg-white rounded-2xl shadow-soft p-8 max-w-md mx-4">
|
|
||||||
<div className="text-center">
|
|
||||||
<AlertCircle className="w-16 h-16 text-orange-500 mx-auto mb-4" />
|
|
||||||
<h3 className="text-xl font-bold text-gray-900 mb-4">
|
|
||||||
Entrenamiento tomando más tiempo
|
|
||||||
</h3>
|
|
||||||
<p className="text-gray-600 mb-6">
|
|
||||||
Puedes explorar el dashboard mientras terminamos el entrenamiento.
|
|
||||||
Te notificaremos cuando esté listo.
|
|
||||||
</p>
|
|
||||||
<div className="flex flex-col sm:flex-row gap-3">
|
|
||||||
<button
|
|
||||||
onClick={handleContinueToDashboard}
|
|
||||||
className="flex-1 bg-primary-500 text-white px-6 py-3 rounded-xl font-medium hover:bg-primary-600 transition-colors"
|
|
||||||
>
|
|
||||||
Continuar al Dashboard
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={handleKeepWaiting}
|
|
||||||
className="flex-1 bg-gray-200 text-gray-800 px-6 py-3 rounded-xl font-medium hover:bg-gray-300 transition-colors"
|
|
||||||
>
|
|
||||||
Seguir Esperando
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
// src/components/ErrorBoundary.tsx
|
|
||||||
import React, { Component, ErrorInfo, ReactNode } from 'react';
|
|
||||||
import { AlertTriangle } from 'lucide-react';
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
children: ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface State {
|
|
||||||
hasError: boolean;
|
|
||||||
error?: Error;
|
|
||||||
}
|
|
||||||
|
|
||||||
class ErrorBoundary extends Component<Props, State> {
|
|
||||||
public state: State = {
|
|
||||||
hasError: false
|
|
||||||
};
|
|
||||||
|
|
||||||
public static getDerivedStateFromError(error: Error): State {
|
|
||||||
return { hasError: true, error };
|
|
||||||
}
|
|
||||||
|
|
||||||
public componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
|
||||||
console.error('ErrorBoundary caught an error:', error, errorInfo);
|
|
||||||
}
|
|
||||||
|
|
||||||
public render() {
|
|
||||||
if (this.state.hasError) {
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 px-4">
|
|
||||||
<div className="max-w-md w-full text-center">
|
|
||||||
<div className="bg-white rounded-2xl p-8 shadow-strong">
|
|
||||||
<div className="mx-auto h-16 w-16 bg-red-100 rounded-full flex items-center justify-center mb-6">
|
|
||||||
<AlertTriangle className="h-8 w-8 text-red-600" />
|
|
||||||
</div>
|
|
||||||
<h1 className="text-xl font-bold text-gray-900 mb-4">
|
|
||||||
¡Oops! Algo salió mal
|
|
||||||
</h1>
|
|
||||||
<p className="text-gray-600 mb-6">
|
|
||||||
Ha ocurrido un error inesperado. Por favor, recarga la página.
|
|
||||||
</p>
|
|
||||||
<button
|
|
||||||
onClick={() => window.location.reload()}
|
|
||||||
className="w-full bg-primary-500 text-white py-3 px-4 rounded-xl font-medium hover:bg-primary-600 transition-colors"
|
|
||||||
>
|
|
||||||
Recargar página
|
|
||||||
</button>
|
|
||||||
{process.env.NODE_ENV === 'development' && (
|
|
||||||
<details className="mt-4 text-left">
|
|
||||||
<summary className="text-sm text-gray-500 cursor-pointer">
|
|
||||||
Detalles del error
|
|
||||||
</summary>
|
|
||||||
<pre className="mt-2 text-xs text-red-600 bg-red-50 p-2 rounded overflow-auto">
|
|
||||||
{this.state.error?.stack}
|
|
||||||
</pre>
|
|
||||||
</details>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.props.children;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default ErrorBoundary;
|
|
||||||
@@ -1,415 +0,0 @@
|
|||||||
import React, { useState, useEffect, useRef } from 'react';
|
|
||||||
import {
|
|
||||||
Sparkles, CheckCircle, Clock, ArrowRight, Coffee,
|
|
||||||
TrendingUp, Target, Loader, AlertTriangle, Mail,
|
|
||||||
ChevronDown, ChevronUp, HelpCircle, ExternalLink
|
|
||||||
} from 'lucide-react';
|
|
||||||
|
|
||||||
interface SimplifiedTrainingProgressProps {
|
|
||||||
progress: {
|
|
||||||
progress: number;
|
|
||||||
status: string;
|
|
||||||
currentStep: string;
|
|
||||||
productsCompleted: number;
|
|
||||||
productsTotal: number;
|
|
||||||
estimatedTimeRemaining: number;
|
|
||||||
error?: string;
|
|
||||||
};
|
|
||||||
onTimeout?: () => void;
|
|
||||||
onBackgroundMode?: () => void;
|
|
||||||
onEmailNotification?: (email: string) => void;
|
|
||||||
// Optional WebSocket debugging info
|
|
||||||
websocketStatus?: string;
|
|
||||||
connectionError?: string;
|
|
||||||
isConnected?: boolean;
|
|
||||||
onRetryConnection?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Proceso simplificado de entrenamiento en 3 etapas
|
|
||||||
const TRAINING_STAGES = [
|
|
||||||
{
|
|
||||||
id: 'preparing',
|
|
||||||
title: 'Preparando Tus Datos',
|
|
||||||
description: 'Estamos organizando tu historial de ventas para encontrar patrones',
|
|
||||||
userFriendly: 'Piensa en esto como ordenar tus recibos para entender tus días de mayor venta',
|
|
||||||
progressRange: [0, 30],
|
|
||||||
icon: Coffee,
|
|
||||||
color: 'blue',
|
|
||||||
celebration: '📊 ¡Los datos están listos!'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'learning',
|
|
||||||
title: 'Construyendo Tu Modelo',
|
|
||||||
description: 'Tu IA está aprendiendo de tus patrones de ventas',
|
|
||||||
userFriendly: 'Como enseñar a un asistente inteligente a reconocer cuándo vendes más pan',
|
|
||||||
progressRange: [30, 80],
|
|
||||||
icon: Sparkles,
|
|
||||||
color: 'purple',
|
|
||||||
celebration: '🧠 ¡Tu IA se está volviendo inteligente!'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'finalizing',
|
|
||||||
title: 'Casi Listo',
|
|
||||||
description: 'Ajustando las predicciones para tu panadería',
|
|
||||||
userFriendly: 'Nos aseguramos de que las predicciones funcionen perfectamente para tu negocio específico',
|
|
||||||
progressRange: [80, 100],
|
|
||||||
icon: Target,
|
|
||||||
color: 'green',
|
|
||||||
celebration: '🎉 ¡Tus predicciones están listas!'
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
const BENEFITS_PREVIEW = [
|
|
||||||
{
|
|
||||||
icon: TrendingUp,
|
|
||||||
title: 'Pronósticos Inteligentes',
|
|
||||||
description: 'Saber exactamente cuánto hornear cada día',
|
|
||||||
example: 'Nunca te quedes sin croissants los domingos ocupados'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: Target,
|
|
||||||
title: 'Reducir Desperdicios',
|
|
||||||
description: 'Deja de hacer demasiado de productos que se venden poco',
|
|
||||||
example: 'Ahorra dinero horneando las cantidades correctas'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: Sparkles,
|
|
||||||
title: 'Detectar Tendencias',
|
|
||||||
description: 'Ve qué productos se están volviendo populares',
|
|
||||||
example: 'Nota cuando los clientes empiezan a amar tu nueva receta'
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function SimplifiedTrainingProgress({
|
|
||||||
progress,
|
|
||||||
onTimeout,
|
|
||||||
onBackgroundMode,
|
|
||||||
onEmailNotification,
|
|
||||||
websocketStatus,
|
|
||||||
connectionError,
|
|
||||||
isConnected,
|
|
||||||
onRetryConnection
|
|
||||||
}: SimplifiedTrainingProgressProps) {
|
|
||||||
const [showDetails, setShowDetails] = useState(false);
|
|
||||||
const [showTimeoutOptions, setShowTimeoutOptions] = useState(false);
|
|
||||||
const [emailForNotification, setEmailForNotification] = useState('');
|
|
||||||
const [celebratingStage, setCelebratingStage] = useState<string | null>(null);
|
|
||||||
const [startTime] = useState(Date.now());
|
|
||||||
const celebratedStagesRef = useRef<Set<string>>(new Set());
|
|
||||||
|
|
||||||
// Show timeout options after 7 minutes for better UX
|
|
||||||
useEffect(() => {
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
if (progress.status === 'running' && progress.progress < 90) {
|
|
||||||
setShowTimeoutOptions(true);
|
|
||||||
}
|
|
||||||
}, 420000); // 7 minutes
|
|
||||||
|
|
||||||
return () => clearTimeout(timer);
|
|
||||||
}, [progress.status, progress.progress]);
|
|
||||||
|
|
||||||
// Celebrate stage completions - fixed to prevent infinite re-renders
|
|
||||||
useEffect(() => {
|
|
||||||
TRAINING_STAGES.forEach(stage => {
|
|
||||||
if (progress.progress >= stage.progressRange[1] &&
|
|
||||||
!celebratedStagesRef.current.has(stage.id) &&
|
|
||||||
progress.progress > 0) {
|
|
||||||
setCelebratingStage(stage.id);
|
|
||||||
celebratedStagesRef.current.add(stage.id);
|
|
||||||
setTimeout(() => setCelebratingStage(null), 3000);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}, [progress.progress]);
|
|
||||||
|
|
||||||
const getCurrentStage = () => {
|
|
||||||
return TRAINING_STAGES.find(stage =>
|
|
||||||
progress.progress >= stage.progressRange[0] &&
|
|
||||||
progress.progress < stage.progressRange[1]
|
|
||||||
) || TRAINING_STAGES[TRAINING_STAGES.length - 1];
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatTimeRemaining = (timeValue: number): string => {
|
|
||||||
if (!timeValue || timeValue <= 0) return 'Casi terminado';
|
|
||||||
|
|
||||||
// Manejar tanto segundos como minutos del backend
|
|
||||||
// Si el valor es muy grande, probablemente sean segundos; si es pequeño, probablemente sean minutos
|
|
||||||
const minutes = timeValue > 120 ? Math.floor(timeValue / 60) : Math.floor(timeValue);
|
|
||||||
|
|
||||||
if (minutes <= 1) return 'Menos de un minuto';
|
|
||||||
if (minutes < 60) return `Aproximadamente ${minutes} minutos restantes`;
|
|
||||||
|
|
||||||
const hours = Math.floor(minutes / 60);
|
|
||||||
const remainingMinutes = minutes % 60;
|
|
||||||
return hours === 1
|
|
||||||
? `Aproximadamente 1 hora ${remainingMinutes > 0 ? `${remainingMinutes} minutos` : ''} restantes`.trim()
|
|
||||||
: `Aproximadamente ${hours} horas ${remainingMinutes > 0 ? `${remainingMinutes} minutos` : ''} restantes`.trim();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleEmailNotification = () => {
|
|
||||||
if (emailForNotification && onEmailNotification) {
|
|
||||||
onEmailNotification(emailForNotification);
|
|
||||||
setShowTimeoutOptions(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const currentStage = getCurrentStage();
|
|
||||||
|
|
||||||
// Error State
|
|
||||||
if (progress.status === 'failed' || progress.error) {
|
|
||||||
return (
|
|
||||||
<div className="max-w-3xl mx-auto text-center">
|
|
||||||
<div className="bg-white rounded-3xl shadow-lg p-8 mb-6">
|
|
||||||
<div className="w-16 h-16 bg-red-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
|
||||||
<AlertTriangle className="w-8 h-8 text-red-500" />
|
|
||||||
</div>
|
|
||||||
<h2 className="text-2xl font-bold text-gray-900 mb-3">
|
|
||||||
Algo salió mal
|
|
||||||
</h2>
|
|
||||||
<p className="text-gray-600 mb-6">
|
|
||||||
No te preocupes - esto pasa a veces. Nuestro equipo ha sido notificado y lo arreglará rápidamente.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="bg-gray-50 rounded-xl p-4 mb-6">
|
|
||||||
<p className="text-sm text-gray-700">
|
|
||||||
Puedes intentar iniciar el entrenamiento de nuevo, o contactar a nuestro equipo de soporte si esto sigue pasando.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={() => window.location.reload()}
|
|
||||||
className="bg-primary-500 text-white px-6 py-3 rounded-xl font-medium hover:bg-primary-600 transition-colors"
|
|
||||||
>
|
|
||||||
Intentar de Nuevo
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="max-w-4xl mx-auto">
|
|
||||||
{/* Main Progress Card */}
|
|
||||||
<div className="bg-white rounded-3xl shadow-lg overflow-hidden mb-6">
|
|
||||||
{/* Header */}
|
|
||||||
<div className="bg-gradient-to-r from-primary-50 to-secondary-50 p-8 text-center">
|
|
||||||
<div className="w-16 h-16 bg-gradient-to-r from-primary-500 to-primary-600 rounded-full flex items-center justify-center mx-auto mb-4">
|
|
||||||
<currentStage.icon className="w-8 h-8 text-white" />
|
|
||||||
</div>
|
|
||||||
<h2 className="text-2xl font-bold text-gray-900 mb-2">
|
|
||||||
{currentStage.title}
|
|
||||||
</h2>
|
|
||||||
<p className="text-lg text-gray-600 mb-4">
|
|
||||||
{currentStage.description}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{/* Progress Bar */}
|
|
||||||
<div className="max-w-md mx-auto">
|
|
||||||
<div className="flex justify-between text-sm text-gray-500 mb-2">
|
|
||||||
<span>Progreso</span>
|
|
||||||
<span>{Math.round(progress.progress)}%</span>
|
|
||||||
</div>
|
|
||||||
<div className="bg-gray-200 rounded-full h-3 overflow-hidden">
|
|
||||||
<div
|
|
||||||
className="bg-gradient-to-r from-primary-500 to-primary-600 h-full transition-all duration-500 ease-out rounded-full"
|
|
||||||
style={{ width: `${progress.progress}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="mt-2 text-sm text-gray-500">
|
|
||||||
<Clock className="w-4 h-4 inline mr-1" />
|
|
||||||
{formatTimeRemaining(progress.estimatedTimeRemaining)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Stage Progress */}
|
|
||||||
<div className="p-6">
|
|
||||||
<div className="flex justify-center space-x-6 mb-6">
|
|
||||||
{TRAINING_STAGES.map((stage, index) => {
|
|
||||||
const isCompleted = progress.progress >= stage.progressRange[1];
|
|
||||||
const isCurrent = stage.id === currentStage.id;
|
|
||||||
const isCelebrating = celebratingStage === stage.id;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div key={stage.id} className="flex flex-col items-center relative">
|
|
||||||
{isCelebrating && (
|
|
||||||
<div className="absolute -top-8 bg-green-100 text-green-800 text-xs px-2 py-1 rounded-full animate-bounce">
|
|
||||||
{stage.celebration}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className={`w-12 h-12 rounded-full flex items-center justify-center transition-all duration-500 ${
|
|
||||||
isCompleted
|
|
||||||
? 'bg-green-500 text-white shadow-lg scale-110'
|
|
||||||
: isCurrent
|
|
||||||
? 'bg-primary-500 text-white animate-pulse shadow-lg'
|
|
||||||
: 'bg-gray-200 text-gray-500'
|
|
||||||
}`}>
|
|
||||||
{isCompleted ? (
|
|
||||||
<CheckCircle className="w-6 h-6" />
|
|
||||||
) : (
|
|
||||||
<stage.icon className="w-6 h-6" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<span className="text-xs text-gray-600 mt-2 text-center">
|
|
||||||
{stage.title.split(' ')[0]}
|
|
||||||
</span>
|
|
||||||
{index < TRAINING_STAGES.length - 1 && (
|
|
||||||
<ArrowRight className={`absolute top-5 -right-3 w-4 h-4 ${
|
|
||||||
isCompleted ? 'text-green-500' : 'text-gray-300'
|
|
||||||
}`} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Current Stage Explanation */}
|
|
||||||
<div className="bg-gray-50 rounded-xl p-4 text-center mb-4">
|
|
||||||
<p className="text-gray-700">
|
|
||||||
{currentStage.userFriendly}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Connection Status Debug Info */}
|
|
||||||
{(websocketStatus || connectionError) && (
|
|
||||||
<div className={`mb-4 p-3 rounded-lg text-sm ${
|
|
||||||
connectionError
|
|
||||||
? 'bg-red-50 text-red-800 border border-red-200'
|
|
||||||
: isConnected
|
|
||||||
? 'bg-green-50 text-green-800 border border-green-200'
|
|
||||||
: 'bg-yellow-50 text-yellow-800 border border-yellow-200'
|
|
||||||
}`}>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<strong>Estado de conexión:</strong>
|
|
||||||
{connectionError
|
|
||||||
? ` Error - ${connectionError}`
|
|
||||||
: isConnected
|
|
||||||
? ' ✅ Conectado a tiempo real'
|
|
||||||
: ' ⏳ Conectando...'}
|
|
||||||
</div>
|
|
||||||
{connectionError && onRetryConnection && (
|
|
||||||
<button
|
|
||||||
onClick={onRetryConnection}
|
|
||||||
className="ml-2 px-3 py-1 bg-red-600 text-white text-xs rounded hover:bg-red-700 transition-colors"
|
|
||||||
>
|
|
||||||
Reintentar
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Optional Details */}
|
|
||||||
<button
|
|
||||||
onClick={() => setShowDetails(!showDetails)}
|
|
||||||
className="flex items-center justify-center w-full text-sm text-gray-500 hover:text-gray-700 transition-colors"
|
|
||||||
>
|
|
||||||
<HelpCircle className="w-4 h-4 mr-1" />
|
|
||||||
{showDetails ? 'Ocultar' : 'Aprende más sobre qué está pasando'}
|
|
||||||
{showDetails ? <ChevronUp className="w-4 h-4 ml-1" /> : <ChevronDown className="w-4 h-4 ml-1" />}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{showDetails && (
|
|
||||||
<div className="mt-4 p-4 bg-blue-50 rounded-xl text-sm text-blue-800">
|
|
||||||
<p className="mb-2">
|
|
||||||
<strong>Detalles técnicos:</strong> Estamos usando aprendizaje automático para analizar tus patrones de ventas,
|
|
||||||
tendencias estacionales y relaciones entre productos para crear predicciones precisas de demanda.
|
|
||||||
</p>
|
|
||||||
<a
|
|
||||||
href="#"
|
|
||||||
className="text-blue-600 hover:text-blue-700 inline-flex items-center"
|
|
||||||
>
|
|
||||||
Aprende más sobre nuestra tecnología de IA
|
|
||||||
<ExternalLink className="w-3 h-3 ml-1" />
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
{/* Benefits Preview */}
|
|
||||||
<div className="bg-white rounded-3xl shadow-lg p-6 mb-6">
|
|
||||||
<h3 className="text-xl font-bold text-gray-900 mb-4 text-center">
|
|
||||||
Lo que obtendrás cuando esto termine
|
|
||||||
</h3>
|
|
||||||
<div className="grid md:grid-cols-3 gap-4">
|
|
||||||
{BENEFITS_PREVIEW.map((benefit, index) => (
|
|
||||||
<div key={index} className="text-center p-4 rounded-xl bg-gradient-to-b from-gray-50 to-white border">
|
|
||||||
<div className="w-12 h-12 bg-blue-100 rounded-full flex items-center justify-center mx-auto mb-3">
|
|
||||||
<benefit.icon className="w-6 h-6 text-blue-600" />
|
|
||||||
</div>
|
|
||||||
<h4 className="font-semibold text-gray-900 mb-2">{benefit.title}</h4>
|
|
||||||
<p className="text-gray-600 text-sm mb-2">{benefit.description}</p>
|
|
||||||
<p className="text-xs text-blue-600 italic">ej., {benefit.example}</p>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Timeout Options */}
|
|
||||||
{showTimeoutOptions && (
|
|
||||||
<div className="bg-yellow-50 border border-yellow-200 rounded-3xl p-6">
|
|
||||||
<div className="text-center mb-4">
|
|
||||||
<Clock className="w-8 h-8 text-yellow-600 mx-auto mb-2" />
|
|
||||||
<h3 className="text-lg font-semibold text-gray-900">¿Tomando más tiempo del esperado?</h3>
|
|
||||||
<p className="text-gray-600">
|
|
||||||
¡No te preocupes! Tienes algunas opciones mientras terminamos.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid md:grid-cols-2 gap-4">
|
|
||||||
{/* Continue in Background */}
|
|
||||||
<div className="bg-white rounded-xl p-4">
|
|
||||||
<h4 className="font-semibold text-gray-900 mb-2">Continuar en segundo plano</h4>
|
|
||||||
<p className="text-sm text-gray-600 mb-3">
|
|
||||||
Explora una vista previa de tu panel de control mientras continúa el entrenamiento.
|
|
||||||
</p>
|
|
||||||
<button
|
|
||||||
onClick={onBackgroundMode}
|
|
||||||
className="w-full bg-primary-500 text-white py-2 px-4 rounded-lg hover:bg-primary-600 transition-colors"
|
|
||||||
>
|
|
||||||
Vista Previa del Panel
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Email Notification */}
|
|
||||||
<div className="bg-white rounded-xl p-4">
|
|
||||||
<h4 className="font-semibold text-gray-900 mb-2">Recibir notificación</h4>
|
|
||||||
<p className="text-sm text-gray-600 mb-3">
|
|
||||||
Te enviaremos un correo cuando tu modelo esté listo.
|
|
||||||
</p>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<input
|
|
||||||
type="email"
|
|
||||||
placeholder="tu@correo.com"
|
|
||||||
value={emailForNotification}
|
|
||||||
onChange={(e) => setEmailForNotification(e.target.value)}
|
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
onClick={handleEmailNotification}
|
|
||||||
disabled={!emailForNotification}
|
|
||||||
className="w-full bg-green-500 text-white py-2 px-4 rounded-lg hover:bg-green-600 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
|
||||||
>
|
|
||||||
<Mail className="w-4 h-4 inline mr-1" />
|
|
||||||
Notificarme
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="text-center mt-4">
|
|
||||||
<button
|
|
||||||
onClick={() => setShowTimeoutOptions(false)}
|
|
||||||
className="text-sm text-gray-500 hover:text-gray-700"
|
|
||||||
>
|
|
||||||
Esperaré aquí
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,205 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { Package, AlertTriangle, TrendingDown, Clock, MapPin } from 'lucide-react';
|
|
||||||
import { useBakeryType } from '../../hooks/useBakeryType';
|
|
||||||
|
|
||||||
interface InventoryItem {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
currentStock: number;
|
|
||||||
minStock: number;
|
|
||||||
unit: string;
|
|
||||||
expiryDate?: string;
|
|
||||||
location?: string;
|
|
||||||
supplier?: string;
|
|
||||||
category: 'ingredient' | 'product' | 'packaging';
|
|
||||||
}
|
|
||||||
|
|
||||||
interface AdaptiveInventoryWidgetProps {
|
|
||||||
items: InventoryItem[];
|
|
||||||
title?: string;
|
|
||||||
showAlerts?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const AdaptiveInventoryWidget: React.FC<AdaptiveInventoryWidgetProps> = ({
|
|
||||||
items,
|
|
||||||
title,
|
|
||||||
showAlerts = true
|
|
||||||
}) => {
|
|
||||||
const { isIndividual, isCentral, getInventoryLabel } = useBakeryType();
|
|
||||||
|
|
||||||
const getStockStatus = (item: InventoryItem) => {
|
|
||||||
const ratio = item.currentStock / item.minStock;
|
|
||||||
if (ratio <= 0.2) return 'critical';
|
|
||||||
if (ratio <= 0.5) return 'low';
|
|
||||||
return 'normal';
|
|
||||||
};
|
|
||||||
|
|
||||||
const getStatusColor = (status: string) => {
|
|
||||||
switch (status) {
|
|
||||||
case 'critical':
|
|
||||||
return 'text-red-600 bg-red-100';
|
|
||||||
case 'low':
|
|
||||||
return 'text-yellow-600 bg-yellow-100';
|
|
||||||
default:
|
|
||||||
return 'text-green-600 bg-green-100';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getExpiryWarning = (expiryDate?: string) => {
|
|
||||||
if (!expiryDate) return null;
|
|
||||||
|
|
||||||
const today = new Date();
|
|
||||||
const expiry = new Date(expiryDate);
|
|
||||||
const daysUntilExpiry = Math.ceil((expiry.getTime() - today.getTime()) / (1000 * 3600 * 24));
|
|
||||||
|
|
||||||
if (daysUntilExpiry <= 1) return 'expires-today';
|
|
||||||
if (daysUntilExpiry <= 3) return 'expires-soon';
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getItemIcon = (category: string) => {
|
|
||||||
if (isIndividual) {
|
|
||||||
switch (category) {
|
|
||||||
case 'ingredient':
|
|
||||||
return '🌾';
|
|
||||||
case 'packaging':
|
|
||||||
return '📦';
|
|
||||||
default:
|
|
||||||
return '🥖';
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
switch (category) {
|
|
||||||
case 'product':
|
|
||||||
return '🥖';
|
|
||||||
case 'packaging':
|
|
||||||
return '📦';
|
|
||||||
default:
|
|
||||||
return '📋';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const filteredItems = items.filter(item => {
|
|
||||||
if (isIndividual) {
|
|
||||||
return item.category === 'ingredient' || item.category === 'packaging';
|
|
||||||
} else {
|
|
||||||
return item.category === 'product' || item.category === 'packaging';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const lowStockItems = filteredItems.filter(item => getStockStatus(item) !== 'normal');
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-center justify-between mb-6">
|
|
||||||
<div className="flex items-center">
|
|
||||||
<Package className="h-5 w-5 text-gray-600 mr-2" />
|
|
||||||
<h3 className="text-lg font-semibold text-gray-900">
|
|
||||||
{title || getInventoryLabel()}
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{showAlerts && lowStockItems.length > 0 && (
|
|
||||||
<div className="flex items-center text-sm text-orange-600 bg-orange-100 px-3 py-1 rounded-full">
|
|
||||||
<AlertTriangle className="h-4 w-4 mr-1" />
|
|
||||||
{lowStockItems.length} alertas
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Items List */}
|
|
||||||
<div className="space-y-3">
|
|
||||||
{filteredItems.slice(0, 6).map((item) => {
|
|
||||||
const stockStatus = getStockStatus(item);
|
|
||||||
const expiryWarning = getExpiryWarning(item.expiryDate);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div key={item.id} className="flex items-center justify-between p-3 border border-gray-200 rounded-lg hover:bg-gray-50 transition-colors">
|
|
||||||
<div className="flex items-center flex-1 min-w-0">
|
|
||||||
<span className="text-2xl mr-3">{getItemIcon(item.category)}</span>
|
|
||||||
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<div className="flex items-center">
|
|
||||||
<h4 className="text-sm font-medium text-gray-900 truncate">
|
|
||||||
{item.name}
|
|
||||||
</h4>
|
|
||||||
|
|
||||||
{stockStatus !== 'normal' && (
|
|
||||||
<span className={`ml-2 px-2 py-1 rounded-full text-xs font-medium ${getStatusColor(stockStatus)}`}>
|
|
||||||
{stockStatus === 'critical' ? 'Crítico' : 'Bajo'}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center space-x-4 mt-1">
|
|
||||||
<span className="text-sm text-gray-600">
|
|
||||||
Stock: {item.currentStock} {item.unit}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
{item.location && isCentral && (
|
|
||||||
<div className="flex items-center text-xs text-gray-500">
|
|
||||||
<MapPin className="h-3 w-3 mr-1" />
|
|
||||||
{item.location}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{expiryWarning && (
|
|
||||||
<div className="flex items-center text-xs text-red-600">
|
|
||||||
<Clock className="h-3 w-3 mr-1" />
|
|
||||||
{expiryWarning === 'expires-today' ? 'Caduca hoy' : 'Caduca pronto'}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{item.supplier && isIndividual && (
|
|
||||||
<div className="text-xs text-gray-500 mt-1">
|
|
||||||
Proveedor: {item.supplier}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="ml-4">
|
|
||||||
<div className="text-right">
|
|
||||||
<div className={`w-3 h-3 rounded-full ${getStatusColor(stockStatus).replace('text-', 'bg-').replace(' bg-', ' ').replace('100', '500')}`}></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Quick Stats */}
|
|
||||||
<div className="mt-6 pt-4 border-t border-gray-200">
|
|
||||||
<div className="grid grid-cols-3 gap-4 text-center">
|
|
||||||
<div>
|
|
||||||
<div className="text-2xl font-bold text-gray-900">{filteredItems.length}</div>
|
|
||||||
<div className="text-xs text-gray-500">
|
|
||||||
{isIndividual ? 'Ingredientes' : 'Productos'}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="text-2xl font-bold text-yellow-600">{lowStockItems.length}</div>
|
|
||||||
<div className="text-xs text-gray-500">Stock bajo</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="text-2xl font-bold text-red-600">
|
|
||||||
{filteredItems.filter(item => getExpiryWarning(item.expiryDate)).length}
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-gray-500">
|
|
||||||
{isIndividual ? 'Próximos a caducar' : 'Próximos a vencer'}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Action Button */}
|
|
||||||
<div className="mt-4">
|
|
||||||
<button className="w-full px-4 py-2 bg-primary-600 text-white text-sm rounded-lg hover:bg-primary-700 transition-colors">
|
|
||||||
Ver Todo el {getInventoryLabel()}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,158 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { ChefHat, Truck, Clock, Users, Package, MapPin } from 'lucide-react';
|
|
||||||
import { useBakeryType } from '../../hooks/useBakeryType';
|
|
||||||
|
|
||||||
interface ProductionItem {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
quantity: number;
|
|
||||||
status: 'pending' | 'in_progress' | 'completed';
|
|
||||||
scheduledTime?: string;
|
|
||||||
location?: string;
|
|
||||||
assignedTo?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface AdaptiveProductionCardProps {
|
|
||||||
item: ProductionItem;
|
|
||||||
onStatusChange?: (id: string, status: string) => void;
|
|
||||||
onQuantityChange?: (id: string, quantity: number) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const AdaptiveProductionCard: React.FC<AdaptiveProductionCardProps> = ({
|
|
||||||
item,
|
|
||||||
onStatusChange,
|
|
||||||
onQuantityChange
|
|
||||||
}) => {
|
|
||||||
const { isIndividual, isCentral, getProductionLabel } = useBakeryType();
|
|
||||||
|
|
||||||
const getStatusColor = (status: string) => {
|
|
||||||
switch (status) {
|
|
||||||
case 'pending':
|
|
||||||
return 'bg-yellow-100 text-yellow-800';
|
|
||||||
case 'in_progress':
|
|
||||||
return 'bg-blue-100 text-blue-800';
|
|
||||||
case 'completed':
|
|
||||||
return 'bg-green-100 text-green-800';
|
|
||||||
default:
|
|
||||||
return 'bg-gray-100 text-gray-800';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getIcon = () => {
|
|
||||||
return isIndividual ? <ChefHat className="h-5 w-5" /> : <Truck className="h-5 w-5" />;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getStatusLabels = () => {
|
|
||||||
if (isIndividual) {
|
|
||||||
return {
|
|
||||||
pending: 'Pendiente',
|
|
||||||
in_progress: 'Horneando',
|
|
||||||
completed: 'Terminado'
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
return {
|
|
||||||
pending: 'Pendiente',
|
|
||||||
in_progress: 'Distribuyendo',
|
|
||||||
completed: 'Entregado'
|
|
||||||
};
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const statusLabels = getStatusLabels();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="bg-white rounded-lg border border-gray-200 p-4 hover:shadow-md transition-shadow">
|
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-center justify-between mb-3">
|
|
||||||
<div className="flex items-center">
|
|
||||||
<div className="h-8 w-8 bg-primary-100 rounded-lg flex items-center justify-center mr-3">
|
|
||||||
{getIcon()}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h4 className="font-medium text-gray-900">{item.name}</h4>
|
|
||||||
<p className="text-sm text-gray-500">
|
|
||||||
{isIndividual ? 'Lote de producción' : 'Envío a puntos de venta'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<span className={`px-2 py-1 rounded-full text-xs font-medium ${getStatusColor(item.status)}`}>
|
|
||||||
{statusLabels[item.status as keyof typeof statusLabels]}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Quantity */}
|
|
||||||
<div className="flex items-center justify-between mb-3">
|
|
||||||
<div className="flex items-center text-sm text-gray-600">
|
|
||||||
<Package className="h-4 w-4 mr-1" />
|
|
||||||
<span>Cantidad:</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center">
|
|
||||||
{onQuantityChange ? (
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
value={item.quantity}
|
|
||||||
onChange={(e) => onQuantityChange(item.id, parseInt(e.target.value))}
|
|
||||||
className="w-20 px-2 py-1 text-sm border border-gray-300 rounded text-right"
|
|
||||||
min="0"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<span className="font-medium">{item.quantity}</span>
|
|
||||||
)}
|
|
||||||
<span className="ml-1 text-sm text-gray-500">
|
|
||||||
{isIndividual ? 'unidades' : 'cajas'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Additional Info for Bakery Type */}
|
|
||||||
{item.scheduledTime && (
|
|
||||||
<div className="flex items-center text-sm text-gray-600 mb-2">
|
|
||||||
<Clock className="h-4 w-4 mr-2" />
|
|
||||||
<span>
|
|
||||||
{isIndividual ? 'Hora de horneado:' : 'Hora de entrega:'} {item.scheduledTime}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{item.location && isCentral && (
|
|
||||||
<div className="flex items-center text-sm text-gray-600 mb-2">
|
|
||||||
<MapPin className="h-4 w-4 mr-2" />
|
|
||||||
<span>Destino: {item.location}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{item.assignedTo && (
|
|
||||||
<div className="flex items-center text-sm text-gray-600 mb-3">
|
|
||||||
<Users className="h-4 w-4 mr-2" />
|
|
||||||
<span>
|
|
||||||
{isIndividual ? 'Panadero:' : 'Conductor:'} {item.assignedTo}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Actions */}
|
|
||||||
{onStatusChange && item.status !== 'completed' && (
|
|
||||||
<div className="flex space-x-2">
|
|
||||||
{item.status === 'pending' && (
|
|
||||||
<button
|
|
||||||
onClick={() => onStatusChange(item.id, 'in_progress')}
|
|
||||||
className="flex-1 px-3 py-2 bg-blue-600 text-white text-sm rounded-lg hover:bg-blue-700 transition-colors"
|
|
||||||
>
|
|
||||||
{isIndividual ? 'Iniciar Horneado' : 'Iniciar Distribución'}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{item.status === 'in_progress' && (
|
|
||||||
<button
|
|
||||||
onClick={() => onStatusChange(item.id, 'completed')}
|
|
||||||
className="flex-1 px-3 py-2 bg-green-600 text-white text-sm rounded-lg hover:bg-green-700 transition-colors"
|
|
||||||
>
|
|
||||||
{isIndividual ? 'Marcar Terminado' : 'Marcar Entregado'}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,304 +0,0 @@
|
|||||||
// frontend/src/components/alerts/AlertCard.tsx
|
|
||||||
/**
|
|
||||||
* Individual alert/recommendation card component
|
|
||||||
* Displays alert details with appropriate styling and actions
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React, { useState } from 'react';
|
|
||||||
import { AlertItem, ItemSeverity, ItemType } from '../../types/alerts';
|
|
||||||
import { formatDistanceToNow } from 'date-fns';
|
|
||||||
import { es } from 'date-fns/locale';
|
|
||||||
|
|
||||||
interface AlertCardProps {
|
|
||||||
item: AlertItem;
|
|
||||||
onAcknowledge: (itemId: string) => void;
|
|
||||||
onResolve: (itemId: string) => void;
|
|
||||||
compact?: boolean;
|
|
||||||
showActions?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const getSeverityConfig = (severity: ItemSeverity, itemType: ItemType) => {
|
|
||||||
if (itemType === 'recommendation') {
|
|
||||||
switch (severity) {
|
|
||||||
case 'high':
|
|
||||||
return {
|
|
||||||
color: 'bg-blue-50 border-blue-200 text-blue-900',
|
|
||||||
icon: '💡',
|
|
||||||
badge: 'bg-blue-100 text-blue-800'
|
|
||||||
};
|
|
||||||
case 'medium':
|
|
||||||
return {
|
|
||||||
color: 'bg-blue-50 border-blue-100 text-blue-800',
|
|
||||||
icon: '💡',
|
|
||||||
badge: 'bg-blue-50 text-blue-600'
|
|
||||||
};
|
|
||||||
case 'low':
|
|
||||||
return {
|
|
||||||
color: 'bg-gray-50 border-gray-200 text-gray-700',
|
|
||||||
icon: '💡',
|
|
||||||
badge: 'bg-gray-100 text-gray-600'
|
|
||||||
};
|
|
||||||
default:
|
|
||||||
return {
|
|
||||||
color: 'bg-blue-50 border-blue-200 text-blue-900',
|
|
||||||
icon: '💡',
|
|
||||||
badge: 'bg-blue-100 text-blue-800'
|
|
||||||
};
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
switch (severity) {
|
|
||||||
case 'urgent':
|
|
||||||
return {
|
|
||||||
color: 'bg-red-50 border-red-300 text-red-900',
|
|
||||||
icon: '🚨',
|
|
||||||
badge: 'bg-red-100 text-red-800',
|
|
||||||
pulse: true
|
|
||||||
};
|
|
||||||
case 'high':
|
|
||||||
return {
|
|
||||||
color: 'bg-orange-50 border-orange-200 text-orange-900',
|
|
||||||
icon: '⚠️',
|
|
||||||
badge: 'bg-orange-100 text-orange-800'
|
|
||||||
};
|
|
||||||
case 'medium':
|
|
||||||
return {
|
|
||||||
color: 'bg-yellow-50 border-yellow-200 text-yellow-900',
|
|
||||||
icon: '🔔',
|
|
||||||
badge: 'bg-yellow-100 text-yellow-800'
|
|
||||||
};
|
|
||||||
case 'low':
|
|
||||||
return {
|
|
||||||
color: 'bg-green-50 border-green-200 text-green-900',
|
|
||||||
icon: 'ℹ️',
|
|
||||||
badge: 'bg-green-100 text-green-800'
|
|
||||||
};
|
|
||||||
default:
|
|
||||||
return {
|
|
||||||
color: 'bg-gray-50 border-gray-200 text-gray-700',
|
|
||||||
icon: '📋',
|
|
||||||
badge: 'bg-gray-100 text-gray-600'
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getStatusConfig = (status: string) => {
|
|
||||||
switch (status) {
|
|
||||||
case 'acknowledged':
|
|
||||||
return {
|
|
||||||
color: 'bg-blue-100 text-blue-800',
|
|
||||||
label: 'Reconocido'
|
|
||||||
};
|
|
||||||
case 'resolved':
|
|
||||||
return {
|
|
||||||
color: 'bg-green-100 text-green-800',
|
|
||||||
label: 'Resuelto'
|
|
||||||
};
|
|
||||||
default:
|
|
||||||
return {
|
|
||||||
color: 'bg-gray-100 text-gray-800',
|
|
||||||
label: 'Activo'
|
|
||||||
};
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const AlertCard: React.FC<AlertCardProps> = ({
|
|
||||||
item,
|
|
||||||
onAcknowledge,
|
|
||||||
onResolve,
|
|
||||||
compact = false,
|
|
||||||
showActions = true
|
|
||||||
}) => {
|
|
||||||
const [isExpanded, setIsExpanded] = useState(false);
|
|
||||||
const [actionLoading, setActionLoading] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const severityConfig = getSeverityConfig(item.severity, item.item_type);
|
|
||||||
const statusConfig = getStatusConfig(item.status);
|
|
||||||
|
|
||||||
const handleAction = async (action: () => void, actionType: string) => {
|
|
||||||
setActionLoading(actionType);
|
|
||||||
try {
|
|
||||||
await action();
|
|
||||||
} finally {
|
|
||||||
setActionLoading(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const timeAgo = formatDistanceToNow(new Date(item.timestamp), {
|
|
||||||
addSuffix: true,
|
|
||||||
locale: es
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={`
|
|
||||||
rounded-lg border-2 transition-all duration-200 hover:shadow-md
|
|
||||||
${severityConfig.color}
|
|
||||||
${severityConfig.pulse ? 'animate-pulse' : ''}
|
|
||||||
${item.status !== 'active' ? 'opacity-75' : ''}
|
|
||||||
`}>
|
|
||||||
{/* Header */}
|
|
||||||
<div className="p-4">
|
|
||||||
<div className="flex items-start justify-between">
|
|
||||||
<div className="flex items-start space-x-3 flex-1 min-w-0">
|
|
||||||
{/* Icon and Type Badge */}
|
|
||||||
<div className="flex-shrink-0">
|
|
||||||
<span className="text-2xl">{severityConfig.icon}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
{/* Title and Badges */}
|
|
||||||
<div className="flex items-start justify-between mb-2">
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<h3 className="text-lg font-semibold truncate">
|
|
||||||
{item.title}
|
|
||||||
</h3>
|
|
||||||
<div className="flex items-center space-x-2 mt-1">
|
|
||||||
<span className={`
|
|
||||||
inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium
|
|
||||||
${severityConfig.badge}
|
|
||||||
`}>
|
|
||||||
{item.item_type === 'alert' ? 'Alerta' : 'Recomendación'} - {item.severity}
|
|
||||||
</span>
|
|
||||||
<span className={`
|
|
||||||
inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium
|
|
||||||
${statusConfig.color}
|
|
||||||
`}>
|
|
||||||
{statusConfig.label}
|
|
||||||
</span>
|
|
||||||
<span className="text-xs text-gray-500">
|
|
||||||
{item.service}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Expand Button */}
|
|
||||||
{!compact && (
|
|
||||||
<button
|
|
||||||
onClick={() => setIsExpanded(!isExpanded)}
|
|
||||||
className="ml-2 text-gray-400 hover:text-gray-600 transition-colors"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
className={`w-5 h-5 transform transition-transform ${
|
|
||||||
isExpanded ? 'rotate-180' : ''
|
|
||||||
}`}
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Message */}
|
|
||||||
<p className={`text-sm ${compact ? 'line-clamp-2' : ''}`}>
|
|
||||||
{item.message}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{/* Timestamp */}
|
|
||||||
<p className="text-xs text-gray-500 mt-2">
|
|
||||||
{timeAgo} • {new Date(item.timestamp).toLocaleString('es-ES')}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Quick Actions */}
|
|
||||||
{showActions && item.status === 'active' && (
|
|
||||||
<div className="flex items-center space-x-2 mt-3">
|
|
||||||
<button
|
|
||||||
onClick={() => handleAction(() => onAcknowledge(item.id), 'acknowledge')}
|
|
||||||
disabled={actionLoading === 'acknowledge'}
|
|
||||||
className="inline-flex items-center px-3 py-1 border border-transparent text-sm font-medium rounded-md text-blue-700 bg-blue-100 hover:bg-blue-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{actionLoading === 'acknowledge' ? (
|
|
||||||
<svg className="animate-spin -ml-1 mr-2 h-4 w-4 text-blue-700" fill="none" viewBox="0 0 24 24">
|
|
||||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
|
||||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
|
||||||
</svg>
|
|
||||||
) : (
|
|
||||||
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
|
||||||
</svg>
|
|
||||||
)}
|
|
||||||
Reconocer
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={() => handleAction(() => onResolve(item.id), 'resolve')}
|
|
||||||
disabled={actionLoading === 'resolve'}
|
|
||||||
className="inline-flex items-center px-3 py-1 border border-transparent text-sm font-medium rounded-md text-green-700 bg-green-100 hover:bg-green-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500 disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{actionLoading === 'resolve' ? (
|
|
||||||
<svg className="animate-spin -ml-1 mr-2 h-4 w-4 text-green-700" fill="none" viewBox="0 0 24 24">
|
|
||||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
|
||||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
|
||||||
</svg>
|
|
||||||
) : (
|
|
||||||
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
||||||
</svg>
|
|
||||||
)}
|
|
||||||
Resolver
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Expanded Details */}
|
|
||||||
{isExpanded && (
|
|
||||||
<div className="border-t border-gray-200 px-4 py-3 bg-gray-50 bg-opacity-50">
|
|
||||||
{/* Actions */}
|
|
||||||
{item.actions.length > 0 && (
|
|
||||||
<div className="mb-3">
|
|
||||||
<h4 className="text-sm font-medium text-gray-700 mb-2">Acciones sugeridas:</h4>
|
|
||||||
<ul className="list-disc list-inside space-y-1">
|
|
||||||
{item.actions.map((action, index) => (
|
|
||||||
<li key={index} className="text-sm text-gray-600">
|
|
||||||
{action}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Metadata */}
|
|
||||||
{Object.keys(item.metadata).length > 0 && (
|
|
||||||
<div className="mb-3">
|
|
||||||
<h4 className="text-sm font-medium text-gray-700 mb-2">Detalles técnicos:</h4>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
|
|
||||||
{Object.entries(item.metadata).map(([key, value]) => (
|
|
||||||
<div key={key} className="text-sm">
|
|
||||||
<span className="font-medium text-gray-600">{key}:</span>{' '}
|
|
||||||
<span className="text-gray-800">
|
|
||||||
{typeof value === 'object' ? JSON.stringify(value) : String(value)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Acknowledgment/Resolution Info */}
|
|
||||||
{(item.acknowledged_at || item.resolved_at) && (
|
|
||||||
<div className="text-xs text-gray-500 space-y-1">
|
|
||||||
{item.acknowledged_at && (
|
|
||||||
<p>
|
|
||||||
Reconocido: {new Date(item.acknowledged_at).toLocaleString('es-ES')}
|
|
||||||
{item.acknowledged_by && ` por ${item.acknowledged_by}`}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{item.resolved_at && (
|
|
||||||
<p>
|
|
||||||
Resuelto: {new Date(item.resolved_at).toLocaleString('es-ES')}
|
|
||||||
{item.resolved_by && ` por ${item.resolved_by}`}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,347 +0,0 @@
|
|||||||
// frontend/src/components/alerts/AlertDashboard.tsx
|
|
||||||
/**
|
|
||||||
* Main dashboard component for alerts and recommendations
|
|
||||||
* Provides filtering, bulk actions, and real-time updates
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React, { useState, useEffect, useMemo } from 'react';
|
|
||||||
import { AlertItem, ItemFilters, ItemType, ItemSeverity, ItemStatus } from '../../types/alerts';
|
|
||||||
import { useAlertStream } from '../../hooks/useAlertStream';
|
|
||||||
import { AlertCard } from './AlertCard';
|
|
||||||
import { AlertFilters } from './AlertFilters';
|
|
||||||
import { AlertStats } from './AlertStats';
|
|
||||||
import { ConnectionStatus } from './ConnectionStatus';
|
|
||||||
import { useTenantId } from '../../hooks/useTenantId';
|
|
||||||
|
|
||||||
interface AlertDashboardProps {
|
|
||||||
className?: string;
|
|
||||||
maxItems?: number;
|
|
||||||
autoRequestNotifications?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const AlertDashboard: React.FC<AlertDashboardProps> = ({
|
|
||||||
className = '',
|
|
||||||
maxItems = 50,
|
|
||||||
autoRequestNotifications = true
|
|
||||||
}) => {
|
|
||||||
const tenantId = useTenantId();
|
|
||||||
const {
|
|
||||||
items,
|
|
||||||
connectionState,
|
|
||||||
urgentCount,
|
|
||||||
highCount,
|
|
||||||
recCount,
|
|
||||||
acknowledgeItem,
|
|
||||||
resolveItem,
|
|
||||||
notificationPermission,
|
|
||||||
requestNotificationPermission
|
|
||||||
} = useAlertStream({ tenantId });
|
|
||||||
|
|
||||||
const [filters, setFilters] = useState<ItemFilters>({
|
|
||||||
item_type: 'all',
|
|
||||||
severity: 'all',
|
|
||||||
status: 'all',
|
|
||||||
service: 'all',
|
|
||||||
search: ''
|
|
||||||
});
|
|
||||||
|
|
||||||
const [selectedItems, setSelectedItems] = useState<string[]>([]);
|
|
||||||
const [bulkActionsOpen, setBulkActionsOpen] = useState(false);
|
|
||||||
const [viewMode, setViewMode] = useState<'list' | 'compact'>('list');
|
|
||||||
|
|
||||||
// Request notification permission on mount if needed
|
|
||||||
useEffect(() => {
|
|
||||||
if (autoRequestNotifications && notificationPermission === 'default') {
|
|
||||||
// Delay request to avoid immediate popup
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
requestNotificationPermission();
|
|
||||||
}, 2000);
|
|
||||||
return () => clearTimeout(timer);
|
|
||||||
}
|
|
||||||
}, [autoRequestNotifications, notificationPermission, requestNotificationPermission]);
|
|
||||||
|
|
||||||
// Filter items based on current filters
|
|
||||||
const filteredItems = useMemo(() => {
|
|
||||||
let filtered = items;
|
|
||||||
|
|
||||||
// Filter by type
|
|
||||||
if (filters.item_type !== 'all') {
|
|
||||||
filtered = filtered.filter(item => item.item_type === filters.item_type);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filter by severity
|
|
||||||
if (filters.severity !== 'all') {
|
|
||||||
filtered = filtered.filter(item => item.severity === filters.severity);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filter by status
|
|
||||||
if (filters.status !== 'all') {
|
|
||||||
filtered = filtered.filter(item => item.status === filters.status);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filter by service
|
|
||||||
if (filters.service !== 'all') {
|
|
||||||
filtered = filtered.filter(item => item.service === filters.service);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filter by search text
|
|
||||||
if (filters.search.trim()) {
|
|
||||||
const searchLower = filters.search.toLowerCase();
|
|
||||||
filtered = filtered.filter(item =>
|
|
||||||
item.title.toLowerCase().includes(searchLower) ||
|
|
||||||
item.message.toLowerCase().includes(searchLower) ||
|
|
||||||
item.type.toLowerCase().includes(searchLower)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return filtered.slice(0, maxItems);
|
|
||||||
}, [items, filters, maxItems]);
|
|
||||||
|
|
||||||
// Get unique services for filter dropdown
|
|
||||||
const availableServices = useMemo(() => {
|
|
||||||
const services = [...new Set(items.map(item => item.service))].sort();
|
|
||||||
return services;
|
|
||||||
}, [items]);
|
|
||||||
|
|
||||||
// Handle bulk actions
|
|
||||||
const handleBulkAcknowledge = async () => {
|
|
||||||
await Promise.all(selectedItems.map(id => acknowledgeItem(id)));
|
|
||||||
setSelectedItems([]);
|
|
||||||
setBulkActionsOpen(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleBulkResolve = async () => {
|
|
||||||
await Promise.all(selectedItems.map(id => resolveItem(id)));
|
|
||||||
setSelectedItems([]);
|
|
||||||
setBulkActionsOpen(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSelectAll = () => {
|
|
||||||
const selectableItems = filteredItems
|
|
||||||
.filter(item => item.status === 'active')
|
|
||||||
.map(item => item.id);
|
|
||||||
setSelectedItems(selectableItems);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleClearSelection = () => {
|
|
||||||
setSelectedItems([]);
|
|
||||||
setBulkActionsOpen(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleItemSelection = (itemId: string) => {
|
|
||||||
setSelectedItems(prev =>
|
|
||||||
prev.includes(itemId)
|
|
||||||
? prev.filter(id => id !== itemId)
|
|
||||||
: [...prev, itemId]
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const activeItems = filteredItems.filter(item => item.status === 'active');
|
|
||||||
const hasSelection = selectedItems.length > 0;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={`max-w-7xl mx-auto ${className}`}>
|
|
||||||
{/* Header */}
|
|
||||||
<div className="bg-white shadow-sm border-b border-gray-200 px-6 py-4">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-2xl font-bold text-gray-900">
|
|
||||||
Sistema de Alertas y Recomendaciones
|
|
||||||
</h1>
|
|
||||||
<p className="text-sm text-gray-600 mt-1">
|
|
||||||
Monitoreo en tiempo real de operaciones de panadería
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Connection Status */}
|
|
||||||
<ConnectionStatus connectionState={connectionState} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Stats */}
|
|
||||||
<AlertStats
|
|
||||||
urgentCount={urgentCount}
|
|
||||||
highCount={highCount}
|
|
||||||
recCount={recCount}
|
|
||||||
totalItems={items.length}
|
|
||||||
activeItems={activeItems.length}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Notification Permission Banner */}
|
|
||||||
{notificationPermission === 'denied' && (
|
|
||||||
<div className="bg-yellow-50 border border-yellow-200 rounded-md p-4 mx-6 mt-4">
|
|
||||||
<div className="flex">
|
|
||||||
<div className="flex-shrink-0">
|
|
||||||
<svg className="h-5 w-5 text-yellow-400" fill="currentColor" viewBox="0 0 20 20">
|
|
||||||
<path fillRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div className="ml-3">
|
|
||||||
<h3 className="text-sm font-medium text-yellow-800">
|
|
||||||
Notificaciones bloqueadas
|
|
||||||
</h3>
|
|
||||||
<p className="text-sm text-yellow-700 mt-1">
|
|
||||||
Las notificaciones del navegador están deshabilitadas. No recibirás alertas urgentes en tiempo real.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Filters and View Controls */}
|
|
||||||
<div className="bg-white border-b border-gray-200 px-6 py-4">
|
|
||||||
<div className="flex flex-col lg:flex-row lg:items-center lg:justify-between space-y-4 lg:space-y-0">
|
|
||||||
<AlertFilters
|
|
||||||
filters={filters}
|
|
||||||
onFiltersChange={setFilters}
|
|
||||||
availableServices={availableServices}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="flex items-center space-x-4">
|
|
||||||
{/* View Mode Toggle */}
|
|
||||||
<div className="flex rounded-md shadow-sm">
|
|
||||||
<button
|
|
||||||
onClick={() => setViewMode('list')}
|
|
||||||
className={`px-4 py-2 text-sm font-medium rounded-l-md border ${
|
|
||||||
viewMode === 'list'
|
|
||||||
? 'bg-blue-50 border-blue-200 text-blue-700'
|
|
||||||
: 'bg-white border-gray-300 text-gray-700 hover:bg-gray-50'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
Lista
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setViewMode('compact')}
|
|
||||||
className={`px-4 py-2 text-sm font-medium rounded-r-md border-l-0 border ${
|
|
||||||
viewMode === 'compact'
|
|
||||||
? 'bg-blue-50 border-blue-200 text-blue-700'
|
|
||||||
: 'bg-white border-gray-300 text-gray-700 hover:bg-gray-50'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
Compacto
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Bulk Actions */}
|
|
||||||
{activeItems.length > 0 && (
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<button
|
|
||||||
onClick={() => setBulkActionsOpen(!bulkActionsOpen)}
|
|
||||||
className="inline-flex items-center px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50"
|
|
||||||
>
|
|
||||||
Acciones masivas
|
|
||||||
<svg className="ml-2 h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Bulk Actions Panel */}
|
|
||||||
{bulkActionsOpen && activeItems.length > 0 && (
|
|
||||||
<div className="mt-4 p-4 bg-gray-50 rounded-lg border border-gray-200">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center space-x-4">
|
|
||||||
<span className="text-sm text-gray-600">
|
|
||||||
{selectedItems.length} elementos seleccionados
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
onClick={handleSelectAll}
|
|
||||||
className="text-sm text-blue-600 hover:text-blue-800"
|
|
||||||
>
|
|
||||||
Seleccionar todos los activos
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={handleClearSelection}
|
|
||||||
className="text-sm text-gray-600 hover:text-gray-800"
|
|
||||||
>
|
|
||||||
Limpiar selección
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{hasSelection && (
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<button
|
|
||||||
onClick={handleBulkAcknowledge}
|
|
||||||
className="inline-flex items-center px-3 py-1 border border-transparent text-sm font-medium rounded-md text-blue-700 bg-blue-100 hover:bg-blue-200"
|
|
||||||
>
|
|
||||||
Reconocer seleccionados
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={handleBulkResolve}
|
|
||||||
className="inline-flex items-center px-3 py-1 border border-transparent text-sm font-medium rounded-md text-green-700 bg-green-100 hover:bg-green-200"
|
|
||||||
>
|
|
||||||
Resolver seleccionados
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Items List */}
|
|
||||||
<div className="px-6 py-4">
|
|
||||||
{filteredItems.length === 0 ? (
|
|
||||||
<div className="text-center py-12">
|
|
||||||
{items.length === 0 ? (
|
|
||||||
<div>
|
|
||||||
<svg className="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
||||||
</svg>
|
|
||||||
<h3 className="mt-2 text-sm font-medium text-gray-900">
|
|
||||||
Sistema operativo
|
|
||||||
</h3>
|
|
||||||
<p className="mt-1 text-sm text-gray-500">
|
|
||||||
No hay alertas activas en este momento. Todas las operaciones funcionan correctamente.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div>
|
|
||||||
<svg className="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
|
||||||
</svg>
|
|
||||||
<h3 className="mt-2 text-sm font-medium text-gray-900">
|
|
||||||
No se encontraron elementos
|
|
||||||
</h3>
|
|
||||||
<p className="mt-1 text-sm text-gray-500">
|
|
||||||
Intenta ajustar los filtros para ver más elementos.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className={`space-y-4 ${viewMode === 'compact' ? 'space-y-2' : ''}`}>
|
|
||||||
{filteredItems.map((item) => (
|
|
||||||
<div key={item.id} className="relative">
|
|
||||||
{/* Selection Checkbox */}
|
|
||||||
{bulkActionsOpen && item.status === 'active' && (
|
|
||||||
<div className="absolute left-2 top-4 z-10">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={selectedItems.includes(item.id)}
|
|
||||||
onChange={() => toggleItemSelection(item.id)}
|
|
||||||
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className={bulkActionsOpen && item.status === 'active' ? 'ml-8' : ''}>
|
|
||||||
<AlertCard
|
|
||||||
item={item}
|
|
||||||
onAcknowledge={acknowledgeItem}
|
|
||||||
onResolve={resolveItem}
|
|
||||||
compact={viewMode === 'compact'}
|
|
||||||
showActions={!bulkActionsOpen}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,148 +0,0 @@
|
|||||||
// frontend/src/components/alerts/AlertFilters.tsx
|
|
||||||
/**
|
|
||||||
* Filter controls for the alert dashboard
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React from 'react';
|
|
||||||
import { ItemFilters, ItemType, ItemSeverity, ItemStatus } from '../../types/alerts';
|
|
||||||
|
|
||||||
interface AlertFiltersProps {
|
|
||||||
filters: ItemFilters;
|
|
||||||
onFiltersChange: (filters: ItemFilters) => void;
|
|
||||||
availableServices: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export const AlertFilters: React.FC<AlertFiltersProps> = ({
|
|
||||||
filters,
|
|
||||||
onFiltersChange,
|
|
||||||
availableServices
|
|
||||||
}) => {
|
|
||||||
const updateFilter = (key: keyof ItemFilters, value: string) => {
|
|
||||||
onFiltersChange({
|
|
||||||
...filters,
|
|
||||||
[key]: value
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center space-y-2 sm:space-y-0 sm:space-x-4">
|
|
||||||
{/* Search */}
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<label htmlFor="search" className="sr-only">
|
|
||||||
Buscar
|
|
||||||
</label>
|
|
||||||
<div className="relative">
|
|
||||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
|
||||||
<svg className="h-5 w-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
id="search"
|
|
||||||
type="text"
|
|
||||||
placeholder="Buscar alertas y recomendaciones..."
|
|
||||||
value={filters.search}
|
|
||||||
onChange={(e) => updateFilter('search', e.target.value)}
|
|
||||||
className="block w-full pl-10 pr-3 py-2 border border-gray-300 rounded-md leading-5 bg-white placeholder-gray-500 focus:outline-none focus:placeholder-gray-400 focus:ring-1 focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Type Filter */}
|
|
||||||
<div>
|
|
||||||
<label htmlFor="type-filter" className="sr-only">
|
|
||||||
Tipo
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
id="type-filter"
|
|
||||||
value={filters.item_type}
|
|
||||||
onChange={(e) => updateFilter('item_type', e.target.value)}
|
|
||||||
className="block w-full pl-3 pr-10 py-2 text-base border border-gray-300 focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm rounded-md"
|
|
||||||
>
|
|
||||||
<option value="all">Todos los tipos</option>
|
|
||||||
<option value="alert">Alertas</option>
|
|
||||||
<option value="recommendation">Recomendaciones</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Severity Filter */}
|
|
||||||
<div>
|
|
||||||
<label htmlFor="severity-filter" className="sr-only">
|
|
||||||
Severidad
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
id="severity-filter"
|
|
||||||
value={filters.severity}
|
|
||||||
onChange={(e) => updateFilter('severity', e.target.value)}
|
|
||||||
className="block w-full pl-3 pr-10 py-2 text-base border border-gray-300 focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm rounded-md"
|
|
||||||
>
|
|
||||||
<option value="all">Todas las severidades</option>
|
|
||||||
<option value="urgent">Urgente</option>
|
|
||||||
<option value="high">Alta</option>
|
|
||||||
<option value="medium">Media</option>
|
|
||||||
<option value="low">Baja</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Status Filter */}
|
|
||||||
<div>
|
|
||||||
<label htmlFor="status-filter" className="sr-only">
|
|
||||||
Estado
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
id="status-filter"
|
|
||||||
value={filters.status}
|
|
||||||
onChange={(e) => updateFilter('status', e.target.value)}
|
|
||||||
className="block w-full pl-3 pr-10 py-2 text-base border border-gray-300 focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm rounded-md"
|
|
||||||
>
|
|
||||||
<option value="all">Todos los estados</option>
|
|
||||||
<option value="active">Activos</option>
|
|
||||||
<option value="acknowledged">Reconocidos</option>
|
|
||||||
<option value="resolved">Resueltos</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Service Filter */}
|
|
||||||
{availableServices.length > 0 && (
|
|
||||||
<div>
|
|
||||||
<label htmlFor="service-filter" className="sr-only">
|
|
||||||
Servicio
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
id="service-filter"
|
|
||||||
value={filters.service}
|
|
||||||
onChange={(e) => updateFilter('service', e.target.value)}
|
|
||||||
className="block w-full pl-3 pr-10 py-2 text-base border border-gray-300 focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm rounded-md"
|
|
||||||
>
|
|
||||||
<option value="all">Todos los servicios</option>
|
|
||||||
{availableServices.map((service) => (
|
|
||||||
<option key={service} value={service}>
|
|
||||||
{service}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Clear Filters */}
|
|
||||||
{(filters.search || filters.item_type !== 'all' || filters.severity !== 'all' ||
|
|
||||||
filters.status !== 'all' || filters.service !== 'all') && (
|
|
||||||
<button
|
|
||||||
onClick={() => onFiltersChange({
|
|
||||||
item_type: 'all',
|
|
||||||
severity: 'all',
|
|
||||||
status: 'all',
|
|
||||||
service: 'all',
|
|
||||||
search: ''
|
|
||||||
})}
|
|
||||||
className="inline-flex items-center px-3 py-2 border border-gray-300 shadow-sm text-sm leading-4 font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
|
||||||
>
|
|
||||||
<svg className="h-4 w-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
|
||||||
</svg>
|
|
||||||
Limpiar
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,102 +0,0 @@
|
|||||||
// frontend/src/components/alerts/AlertStats.tsx
|
|
||||||
/**
|
|
||||||
* Statistics display for alerts and recommendations
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React from 'react';
|
|
||||||
|
|
||||||
interface AlertStatsProps {
|
|
||||||
urgentCount: number;
|
|
||||||
highCount: number;
|
|
||||||
recCount: number;
|
|
||||||
totalItems: number;
|
|
||||||
activeItems: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const AlertStats: React.FC<AlertStatsProps> = ({
|
|
||||||
urgentCount,
|
|
||||||
highCount,
|
|
||||||
recCount,
|
|
||||||
totalItems,
|
|
||||||
activeItems
|
|
||||||
}) => {
|
|
||||||
const stats = [
|
|
||||||
{
|
|
||||||
name: 'Alertas Urgentes',
|
|
||||||
value: urgentCount,
|
|
||||||
icon: '🚨',
|
|
||||||
color: urgentCount > 0 ? 'text-red-600' : 'text-gray-600',
|
|
||||||
bgColor: urgentCount > 0 ? 'bg-red-50' : 'bg-gray-50',
|
|
||||||
borderColor: urgentCount > 0 ? 'border-red-200' : 'border-gray-200'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Alertas Altas',
|
|
||||||
value: highCount,
|
|
||||||
icon: '⚠️',
|
|
||||||
color: highCount > 0 ? 'text-orange-600' : 'text-gray-600',
|
|
||||||
bgColor: highCount > 0 ? 'bg-orange-50' : 'bg-gray-50',
|
|
||||||
borderColor: highCount > 0 ? 'border-orange-200' : 'border-gray-200'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Recomendaciones',
|
|
||||||
value: recCount,
|
|
||||||
icon: '💡',
|
|
||||||
color: recCount > 0 ? 'text-blue-600' : 'text-gray-600',
|
|
||||||
bgColor: recCount > 0 ? 'bg-blue-50' : 'bg-gray-50',
|
|
||||||
borderColor: recCount > 0 ? 'border-blue-200' : 'border-gray-200'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Total Activos',
|
|
||||||
value: activeItems,
|
|
||||||
icon: '📊',
|
|
||||||
color: 'text-gray-600',
|
|
||||||
bgColor: 'bg-gray-50',
|
|
||||||
borderColor: 'border-gray-200'
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="bg-white border-b border-gray-200">
|
|
||||||
<div className="px-6 py-4">
|
|
||||||
<dl className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
|
||||||
{stats.map((stat) => (
|
|
||||||
<div
|
|
||||||
key={stat.name}
|
|
||||||
className={`relative overflow-hidden rounded-lg border ${stat.borderColor} ${stat.bgColor} p-4 transition-all duration-200 hover:shadow-md`}
|
|
||||||
>
|
|
||||||
<dt className="flex items-center text-sm font-medium text-gray-600">
|
|
||||||
<span className="text-lg mr-2">{stat.icon}</span>
|
|
||||||
{stat.name}
|
|
||||||
</dt>
|
|
||||||
<dd className={`mt-1 text-2xl font-semibold ${stat.color}`}>
|
|
||||||
{stat.value}
|
|
||||||
</dd>
|
|
||||||
|
|
||||||
{/* Pulse animation for urgent alerts */}
|
|
||||||
{stat.name === 'Alertas Urgentes' && urgentCount > 0 && (
|
|
||||||
<div className="absolute inset-0 rounded-lg border-2 border-red-400 animate-pulse opacity-50"></div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</dl>
|
|
||||||
|
|
||||||
{/* Summary text */}
|
|
||||||
<div className="mt-4 text-sm text-gray-600">
|
|
||||||
{totalItems === 0 ? (
|
|
||||||
<p className="flex items-center">
|
|
||||||
<span className="text-green-500 mr-2">✅</span>
|
|
||||||
Todos los sistemas funcionan correctamente
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<p>
|
|
||||||
Mostrando {totalItems} elementos total{totalItems !== 1 ? 'es' : ''}
|
|
||||||
{activeItems > 0 && (
|
|
||||||
<>, {activeItems} activo{activeItems !== 1 ? 's' : ''}</>
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
// frontend/src/components/alerts/ConnectionStatus.tsx
|
|
||||||
/**
|
|
||||||
* Displays the current SSE connection status with appropriate styling
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React from 'react';
|
|
||||||
import { SSEConnectionState } from '../../types/alerts';
|
|
||||||
|
|
||||||
interface ConnectionStatusProps {
|
|
||||||
connectionState: SSEConnectionState;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ConnectionStatus: React.FC<ConnectionStatusProps> = ({
|
|
||||||
connectionState
|
|
||||||
}) => {
|
|
||||||
const getStatusConfig = (state: SSEConnectionState) => {
|
|
||||||
switch (state.status) {
|
|
||||||
case 'connected':
|
|
||||||
return {
|
|
||||||
color: 'bg-green-100 text-green-800 border-green-200',
|
|
||||||
icon: '🟢',
|
|
||||||
label: 'Conectado',
|
|
||||||
description: 'Actualizaciones en tiempo real'
|
|
||||||
};
|
|
||||||
case 'connecting':
|
|
||||||
return {
|
|
||||||
color: 'bg-yellow-100 text-yellow-800 border-yellow-200',
|
|
||||||
icon: '🟡',
|
|
||||||
label: 'Conectando...',
|
|
||||||
description: 'Estableciendo conexión'
|
|
||||||
};
|
|
||||||
case 'error':
|
|
||||||
return {
|
|
||||||
color: 'bg-red-100 text-red-800 border-red-200',
|
|
||||||
icon: '🔴',
|
|
||||||
label: 'Error de conexión',
|
|
||||||
description: state.reconnectAttempts > 0 ? `Reintento ${state.reconnectAttempts}` : 'Fallo en la conexión'
|
|
||||||
};
|
|
||||||
case 'disconnected':
|
|
||||||
default:
|
|
||||||
return {
|
|
||||||
color: 'bg-gray-100 text-gray-800 border-gray-200',
|
|
||||||
icon: '⚪',
|
|
||||||
label: 'Desconectado',
|
|
||||||
description: 'Sin actualizaciones en tiempo real'
|
|
||||||
};
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const config = getStatusConfig(connectionState);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={`inline-flex items-center px-3 py-2 rounded-md border text-sm font-medium ${config.color}`}>
|
|
||||||
<span className="mr-2">{config.icon}</span>
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<span className="font-medium">{config.label}</span>
|
|
||||||
<span className="text-xs opacity-75">{config.description}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{connectionState.status === 'connecting' && (
|
|
||||||
<div className="ml-2">
|
|
||||||
<svg className="animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24">
|
|
||||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
|
||||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
|
||||||
import { Navigate, useLocation } from 'react-router-dom';
|
|
||||||
import { useSelector, useDispatch } from 'react-redux';
|
|
||||||
import { RootState } from '../../store';
|
|
||||||
import { completeOnboarding } from '../../store/slices/authSlice';
|
|
||||||
import { OnboardingRouter } from '../../utils/onboardingRouter';
|
|
||||||
|
|
||||||
interface ProtectedRouteProps {
|
|
||||||
children: React.ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ProtectedRoute: React.FC<ProtectedRouteProps> = ({ children }) => {
|
|
||||||
const location = useLocation();
|
|
||||||
const dispatch = useDispatch();
|
|
||||||
const { isAuthenticated, user } = useSelector((state: RootState) => state.auth);
|
|
||||||
const [onboardingCheck, setOnboardingCheck] = useState<'checking' | 'complete' | 'incomplete'>('checking');
|
|
||||||
|
|
||||||
// Check if user is authenticated
|
|
||||||
if (!isAuthenticated || !user) {
|
|
||||||
// Redirect to login with the attempted location
|
|
||||||
return <Navigate to="/login" state={{ from: location }} replace />;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sync onboarding status with backend on mount and navigation
|
|
||||||
useEffect(() => {
|
|
||||||
const checkOnboardingStatus = async () => {
|
|
||||||
try {
|
|
||||||
// If user already marked as onboarding complete, skip API check
|
|
||||||
if (user.isOnboardingComplete) {
|
|
||||||
setOnboardingCheck('complete');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check actual onboarding progress from backend
|
|
||||||
const canAccess = await OnboardingRouter.canAccessDashboard();
|
|
||||||
|
|
||||||
if (canAccess) {
|
|
||||||
// User has completed onboarding according to backend, update Redux state
|
|
||||||
dispatch(completeOnboarding());
|
|
||||||
setOnboardingCheck('complete');
|
|
||||||
} else {
|
|
||||||
setOnboardingCheck('incomplete');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error checking onboarding status:', error);
|
|
||||||
// On error, use current Redux state
|
|
||||||
setOnboardingCheck(user.isOnboardingComplete ? 'complete' : 'incomplete');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
checkOnboardingStatus();
|
|
||||||
}, [user.isOnboardingComplete, dispatch]);
|
|
||||||
|
|
||||||
// Show loading while checking onboarding status
|
|
||||||
if (onboardingCheck === 'checking') {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-center h-screen">
|
|
||||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Route-based logic
|
|
||||||
const isOnboardingRoute = location.pathname.includes('/onboarding');
|
|
||||||
const isSettingsRoute = location.pathname.includes('/settings');
|
|
||||||
|
|
||||||
// If onboarding not complete and not on onboarding/settings route, redirect to onboarding
|
|
||||||
if (onboardingCheck === 'incomplete' && !isOnboardingRoute && !isSettingsRoute) {
|
|
||||||
return <Navigate to="/app/onboarding" replace />;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If onboarding complete but on onboarding route, redirect to dashboard
|
|
||||||
if (onboardingCheck === 'complete' && isOnboardingRoute) {
|
|
||||||
return <Navigate to="/app/dashboard" replace />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return <>{children}</>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ProtectedRoute;
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { usePermissions, UserRole } from '../../hooks/usePermissions';
|
|
||||||
|
|
||||||
interface RoleBasedAccessProps {
|
|
||||||
children: React.ReactNode;
|
|
||||||
requiredRoles?: UserRole[];
|
|
||||||
requiredPermissions?: string[];
|
|
||||||
fallback?: React.ReactNode;
|
|
||||||
hideIfNoAccess?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const RoleBasedAccess: React.FC<RoleBasedAccessProps> = ({
|
|
||||||
children,
|
|
||||||
requiredRoles = [],
|
|
||||||
requiredPermissions = [],
|
|
||||||
fallback = null,
|
|
||||||
hideIfNoAccess = false
|
|
||||||
}) => {
|
|
||||||
const { hasRole, hasPermission } = usePermissions();
|
|
||||||
|
|
||||||
// Check role requirements
|
|
||||||
const hasRequiredRole = requiredRoles.length === 0 || requiredRoles.some(role => hasRole(role));
|
|
||||||
|
|
||||||
// Check permission requirements
|
|
||||||
const hasRequiredPermission = requiredPermissions.length === 0 || requiredPermissions.some(permission => hasPermission(permission));
|
|
||||||
|
|
||||||
const hasAccess = hasRequiredRole && hasRequiredPermission;
|
|
||||||
|
|
||||||
if (!hasAccess) {
|
|
||||||
if (hideIfNoAccess) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return <>{fallback}</>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return <>{children}</>;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Convenience components for common use cases
|
|
||||||
export const AdminOnly: React.FC<{ children: React.ReactNode; fallback?: React.ReactNode }> = ({ children, fallback }) => (
|
|
||||||
<RoleBasedAccess requiredRoles={['admin', 'owner']} fallback={fallback}>
|
|
||||||
{children}
|
|
||||||
</RoleBasedAccess>
|
|
||||||
);
|
|
||||||
|
|
||||||
export const ManagerAndUp: React.FC<{ children: React.ReactNode; fallback?: React.ReactNode }> = ({ children, fallback }) => (
|
|
||||||
<RoleBasedAccess requiredRoles={['manager', 'admin', 'owner']} fallback={fallback}>
|
|
||||||
{children}
|
|
||||||
</RoleBasedAccess>
|
|
||||||
);
|
|
||||||
|
|
||||||
export const OwnerOnly: React.FC<{ children: React.ReactNode; fallback?: React.ReactNode }> = ({ children, fallback }) => (
|
|
||||||
<RoleBasedAccess requiredRoles={['owner']} fallback={fallback}>
|
|
||||||
{children}
|
|
||||||
</RoleBasedAccess>
|
|
||||||
);
|
|
||||||
|
|
||||||
export default RoleBasedAccess;
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { Navigate } from 'react-router-dom';
|
|
||||||
import { useSelector } from 'react-redux';
|
|
||||||
import { RootState } from '../../store';
|
|
||||||
import { usePermissions } from '../../hooks/usePermissions';
|
|
||||||
|
|
||||||
interface RoleBasedRouteProps {
|
|
||||||
children: React.ReactNode;
|
|
||||||
requiredRoles?: string[];
|
|
||||||
requiredPermissions?: string[];
|
|
||||||
fallbackPath?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const RoleBasedRoute: React.FC<RoleBasedRouteProps> = ({
|
|
||||||
children,
|
|
||||||
requiredRoles = [],
|
|
||||||
requiredPermissions = [],
|
|
||||||
fallbackPath = '/app/dashboard'
|
|
||||||
}) => {
|
|
||||||
const { user } = useSelector((state: RootState) => state.auth);
|
|
||||||
const { hasRole, hasPermission } = usePermissions();
|
|
||||||
|
|
||||||
// Check role requirements
|
|
||||||
if (requiredRoles.length > 0) {
|
|
||||||
const hasRequiredRole = requiredRoles.some(role => hasRole(role));
|
|
||||||
if (!hasRequiredRole) {
|
|
||||||
return <Navigate to={fallbackPath} replace />;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check permission requirements
|
|
||||||
if (requiredPermissions.length > 0) {
|
|
||||||
const hasRequiredPermission = requiredPermissions.some(permission => hasPermission(permission));
|
|
||||||
if (!hasRequiredPermission) {
|
|
||||||
return <Navigate to={fallbackPath} replace />;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return <>{children}</>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default RoleBasedRoute;
|
|
||||||
@@ -1,249 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import {
|
|
||||||
Package,
|
|
||||||
TrendingDown,
|
|
||||||
AlertTriangle,
|
|
||||||
Calendar,
|
|
||||||
BarChart3,
|
|
||||||
ArrowRight,
|
|
||||||
Loader,
|
|
||||||
RefreshCw
|
|
||||||
} from 'lucide-react';
|
|
||||||
|
|
||||||
import { useInventoryDashboard } from '../../api/hooks/useInventory';
|
|
||||||
|
|
||||||
interface InventoryDashboardWidgetProps {
|
|
||||||
onViewInventory?: () => void;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const InventoryDashboardWidget: React.FC<InventoryDashboardWidgetProps> = ({
|
|
||||||
onViewInventory,
|
|
||||||
className = ''
|
|
||||||
}) => {
|
|
||||||
const { dashboardData, isLoading, error, refresh } = useInventoryDashboard();
|
|
||||||
|
|
||||||
// Get alert counts
|
|
||||||
const criticalAlerts = 0;
|
|
||||||
const lowStockAlerts = 0;
|
|
||||||
const expiringAlerts = 0;
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<div className={`bg-white rounded-xl shadow-sm border p-6 ${className}`}>
|
|
||||||
<div className="flex items-center space-x-3 mb-4">
|
|
||||||
<Package className="w-6 h-6 text-blue-600" />
|
|
||||||
<h3 className="text-lg font-semibold text-gray-900">Inventario</h3>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-center py-8">
|
|
||||||
<Loader className="w-6 h-6 animate-spin text-blue-600" />
|
|
||||||
<span className="ml-3 text-gray-600">Cargando datos...</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return (
|
|
||||||
<div className={`bg-white rounded-xl shadow-sm border p-6 ${className}`}>
|
|
||||||
<div className="flex items-center justify-between mb-4">
|
|
||||||
<div className="flex items-center space-x-3">
|
|
||||||
<Package className="w-6 h-6 text-blue-600" />
|
|
||||||
<h3 className="text-lg font-semibold text-gray-900">Inventario</h3>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={refresh}
|
|
||||||
className="p-1 hover:bg-gray-100 rounded"
|
|
||||||
title="Refrescar"
|
|
||||||
>
|
|
||||||
<RefreshCw className="w-4 h-4 text-gray-600" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="text-center py-6">
|
|
||||||
<AlertTriangle className="w-8 h-8 text-red-500 mx-auto mb-2" />
|
|
||||||
<p className="text-sm text-red-600">Error al cargar datos de inventario</p>
|
|
||||||
<button
|
|
||||||
onClick={refresh}
|
|
||||||
className="mt-2 text-xs text-blue-600 hover:text-blue-800"
|
|
||||||
>
|
|
||||||
Reintentar
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={`bg-white rounded-xl shadow-sm border ${className}`}>
|
|
||||||
{/* Header */}
|
|
||||||
<div className="p-6 pb-4">
|
|
||||||
<div className="flex items-center justify-between mb-6">
|
|
||||||
<div className="flex items-center space-x-3">
|
|
||||||
<Package className="w-6 h-6 text-blue-600" />
|
|
||||||
<h3 className="text-lg font-semibold text-gray-900">Inventario</h3>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<button
|
|
||||||
onClick={refresh}
|
|
||||||
className="p-1 hover:bg-gray-100 rounded transition-colors"
|
|
||||||
title="Refrescar"
|
|
||||||
>
|
|
||||||
<RefreshCw className="w-4 h-4 text-gray-600" />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{onViewInventory && (
|
|
||||||
<button
|
|
||||||
onClick={onViewInventory}
|
|
||||||
className="flex items-center space-x-1 px-3 py-1 text-sm text-blue-600 hover:bg-blue-50 rounded-lg transition-colors"
|
|
||||||
>
|
|
||||||
<span>Ver todo</span>
|
|
||||||
<ArrowRight className="w-3 h-3" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Quick Stats */}
|
|
||||||
<div className="grid grid-cols-2 gap-4 mb-6">
|
|
||||||
<div className="text-center">
|
|
||||||
<div className="text-2xl font-bold text-gray-900">
|
|
||||||
{dashboardData?.total_items || 0}
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-gray-600">Total Productos</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="text-center">
|
|
||||||
<div className="text-2xl font-bold text-green-600">
|
|
||||||
€{(dashboardData?.total_value || 0).toLocaleString()}
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-gray-600">Valor Total</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Alerts Summary */}
|
|
||||||
{criticalAlerts > 0 || lowStockAlerts > 0 || expiringAlerts > 0 ? (
|
|
||||||
<div className="space-y-3">
|
|
||||||
<h4 className="text-sm font-medium text-gray-900 flex items-center">
|
|
||||||
<AlertTriangle className="w-4 h-4 text-amber-500 mr-2" />
|
|
||||||
Alertas Activas
|
|
||||||
</h4>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
{criticalAlerts > 0 && (
|
|
||||||
<div className="flex items-center justify-between p-3 bg-red-50 border border-red-200 rounded-lg">
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<div className="w-2 h-2 bg-red-500 rounded-full"></div>
|
|
||||||
<span className="text-sm text-red-800">Críticas</span>
|
|
||||||
</div>
|
|
||||||
<span className="text-sm font-medium text-red-900">{criticalAlerts}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{lowStockAlerts > 0 && (
|
|
||||||
<div className="flex items-center justify-between p-3 bg-yellow-50 border border-yellow-200 rounded-lg">
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<TrendingDown className="w-4 h-4 text-yellow-600" />
|
|
||||||
<span className="text-sm text-yellow-800">Stock Bajo</span>
|
|
||||||
</div>
|
|
||||||
<span className="text-sm font-medium text-yellow-900">{lowStockAlerts}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{expiringAlerts > 0 && (
|
|
||||||
<div className="flex items-center justify-between p-3 bg-orange-50 border border-orange-200 rounded-lg">
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<Calendar className="w-4 h-4 text-orange-600" />
|
|
||||||
<span className="text-sm text-orange-800">Por Vencer</span>
|
|
||||||
</div>
|
|
||||||
<span className="text-sm font-medium text-orange-900">{expiringAlerts}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="text-center py-4">
|
|
||||||
<div className="w-12 h-12 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-3">
|
|
||||||
<Package className="w-6 h-6 text-green-600" />
|
|
||||||
</div>
|
|
||||||
<h4 className="text-sm font-medium text-gray-900 mb-1">Todo en orden</h4>
|
|
||||||
<p className="text-xs text-gray-600">No hay alertas activas en tu inventario</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Top Categories */}
|
|
||||||
{dashboardData?.category_breakdown && dashboardData.category_breakdown.length > 0 && (
|
|
||||||
<div className="mt-6 pt-4 border-t">
|
|
||||||
<h4 className="text-sm font-medium text-gray-900 mb-3 flex items-center">
|
|
||||||
<BarChart3 className="w-4 h-4 text-gray-600 mr-2" />
|
|
||||||
Top Categorías por Valor
|
|
||||||
</h4>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
{dashboardData.category_breakdown.slice(0, 3).map((category, index) => (
|
|
||||||
<div key={category.category} className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<div className={`w-2 h-2 rounded-full ${
|
|
||||||
index === 0 ? 'bg-blue-500' :
|
|
||||||
index === 1 ? 'bg-green-500' :
|
|
||||||
'bg-purple-500'
|
|
||||||
}`}></div>
|
|
||||||
<span className="text-sm text-gray-700 capitalize">
|
|
||||||
{category.category}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="text-right">
|
|
||||||
<div className="text-sm font-medium text-gray-900">
|
|
||||||
€{category.value.toLocaleString()}
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-gray-500">
|
|
||||||
{category.count} productos
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Recent Activity */}
|
|
||||||
{dashboardData?.recent_movements && dashboardData.recent_movements.length > 0 && (
|
|
||||||
<div className="mt-6 pt-4 border-t">
|
|
||||||
<h4 className="text-sm font-medium text-gray-900 mb-3">Actividad Reciente</h4>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
{dashboardData.recent_movements.slice(0, 3).map((movement) => (
|
|
||||||
<div key={movement.id} className="flex items-center space-x-3 text-sm">
|
|
||||||
<div className={`w-6 h-6 rounded-full flex items-center justify-center ${
|
|
||||||
movement.movement_type === 'purchase' ? 'bg-green-100' :
|
|
||||||
movement.movement_type === 'consumption' ? 'bg-blue-100' :
|
|
||||||
movement.movement_type === 'waste' ? 'bg-red-100' :
|
|
||||||
'bg-gray-100'
|
|
||||||
}`}>
|
|
||||||
{movement.movement_type === 'purchase' ? '+' :
|
|
||||||
movement.movement_type === 'consumption' ? '-' :
|
|
||||||
movement.movement_type === 'waste' ? '×' :
|
|
||||||
'~'}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<div className="font-medium text-gray-900 truncate">
|
|
||||||
{movement.item_name || 'Producto'}
|
|
||||||
</div>
|
|
||||||
<div className="text-gray-500">
|
|
||||||
{movement.quantity} • {new Date(movement.movement_date).toLocaleDateString()}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default InventoryDashboardWidget;
|
|
||||||
@@ -1,424 +0,0 @@
|
|||||||
import React, { useState } from 'react';
|
|
||||||
import {
|
|
||||||
Package,
|
|
||||||
AlertTriangle,
|
|
||||||
Clock,
|
|
||||||
Thermometer,
|
|
||||||
Snowflake,
|
|
||||||
Calendar,
|
|
||||||
TrendingDown,
|
|
||||||
TrendingUp,
|
|
||||||
Edit3,
|
|
||||||
Trash2,
|
|
||||||
Plus,
|
|
||||||
Minus,
|
|
||||||
Eye,
|
|
||||||
MoreVertical
|
|
||||||
} from 'lucide-react';
|
|
||||||
|
|
||||||
import {
|
|
||||||
InventoryItem,
|
|
||||||
StockLevel,
|
|
||||||
ProductType,
|
|
||||||
StockAdjustmentRequest
|
|
||||||
} from '../../api/services/inventory.service';
|
|
||||||
|
|
||||||
interface InventoryItemCardProps {
|
|
||||||
item: InventoryItem;
|
|
||||||
stockLevel?: StockLevel;
|
|
||||||
compact?: boolean;
|
|
||||||
showActions?: boolean;
|
|
||||||
onEdit?: (item: InventoryItem) => void;
|
|
||||||
onDelete?: (item: InventoryItem) => void;
|
|
||||||
onViewDetails?: (item: InventoryItem) => void;
|
|
||||||
onStockAdjust?: (item: InventoryItem, adjustment: StockAdjustmentRequest) => void;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const InventoryItemCard: React.FC<InventoryItemCardProps> = ({
|
|
||||||
item,
|
|
||||||
stockLevel,
|
|
||||||
compact = false,
|
|
||||||
showActions = true,
|
|
||||||
onEdit,
|
|
||||||
onDelete,
|
|
||||||
onViewDetails,
|
|
||||||
onStockAdjust,
|
|
||||||
className = ''
|
|
||||||
}) => {
|
|
||||||
const [showQuickAdjust, setShowQuickAdjust] = useState(false);
|
|
||||||
const [adjustmentQuantity, setAdjustmentQuantity] = useState('');
|
|
||||||
|
|
||||||
// Get stock status
|
|
||||||
const getStockStatus = () => {
|
|
||||||
if (!stockLevel) return null;
|
|
||||||
|
|
||||||
const { current_quantity, available_quantity } = stockLevel;
|
|
||||||
const { minimum_stock_level, reorder_point } = item;
|
|
||||||
|
|
||||||
if (current_quantity <= 0) {
|
|
||||||
return { status: 'out_of_stock', label: 'Sin stock', color: 'red' };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (minimum_stock_level && current_quantity <= minimum_stock_level) {
|
|
||||||
return { status: 'low_stock', label: 'Stock bajo', color: 'yellow' };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (reorder_point && current_quantity <= reorder_point) {
|
|
||||||
return { status: 'reorder', label: 'Reordenar', color: 'orange' };
|
|
||||||
}
|
|
||||||
|
|
||||||
return { status: 'good', label: 'Stock OK', color: 'green' };
|
|
||||||
};
|
|
||||||
|
|
||||||
const stockStatus = getStockStatus();
|
|
||||||
|
|
||||||
// Get expiration status
|
|
||||||
const getExpirationStatus = () => {
|
|
||||||
if (!stockLevel?.batches || stockLevel.batches.length === 0) return null;
|
|
||||||
|
|
||||||
const expiredBatches = stockLevel.batches.filter(b => b.is_expired);
|
|
||||||
const expiringSoon = stockLevel.batches.filter(b =>
|
|
||||||
!b.is_expired && b.days_until_expiration !== undefined && b.days_until_expiration <= 3
|
|
||||||
);
|
|
||||||
|
|
||||||
if (expiredBatches.length > 0) {
|
|
||||||
return { status: 'expired', label: 'Vencido', color: 'red' };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (expiringSoon.length > 0) {
|
|
||||||
return { status: 'expiring', label: 'Por vencer', color: 'yellow' };
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const expirationStatus = getExpirationStatus();
|
|
||||||
|
|
||||||
// Get category display info
|
|
||||||
const getCategoryInfo = () => {
|
|
||||||
const categoryLabels: Record<string, string> = {
|
|
||||||
// Ingredients
|
|
||||||
flour: 'Harina',
|
|
||||||
yeast: 'Levadura',
|
|
||||||
dairy: 'Lácteos',
|
|
||||||
eggs: 'Huevos',
|
|
||||||
sugar: 'Azúcar',
|
|
||||||
fats: 'Grasas',
|
|
||||||
salt: 'Sal',
|
|
||||||
spices: 'Especias',
|
|
||||||
additives: 'Aditivos',
|
|
||||||
packaging: 'Embalaje',
|
|
||||||
|
|
||||||
// Finished Products
|
|
||||||
bread: 'Pan',
|
|
||||||
croissants: 'Croissants',
|
|
||||||
pastries: 'Repostería',
|
|
||||||
cakes: 'Tartas',
|
|
||||||
cookies: 'Galletas',
|
|
||||||
muffins: 'Magdalenas',
|
|
||||||
sandwiches: 'Sandwiches',
|
|
||||||
beverages: 'Bebidas',
|
|
||||||
other_products: 'Otros'
|
|
||||||
};
|
|
||||||
|
|
||||||
return categoryLabels[item.category] || item.category;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Handle quick stock adjustment
|
|
||||||
const handleQuickAdjust = (type: 'add' | 'remove') => {
|
|
||||||
if (!adjustmentQuantity || !onStockAdjust) return;
|
|
||||||
|
|
||||||
const quantity = parseFloat(adjustmentQuantity);
|
|
||||||
if (isNaN(quantity) || quantity <= 0) return;
|
|
||||||
|
|
||||||
const adjustment: StockAdjustmentRequest = {
|
|
||||||
movement_type: type === 'add' ? 'purchase' : 'consumption',
|
|
||||||
quantity: type === 'add' ? quantity : -quantity,
|
|
||||||
notes: `Quick ${type === 'add' ? 'addition' : 'consumption'} via inventory card`
|
|
||||||
};
|
|
||||||
|
|
||||||
onStockAdjust(item, adjustment);
|
|
||||||
setAdjustmentQuantity('');
|
|
||||||
setShowQuickAdjust(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (compact) {
|
|
||||||
return (
|
|
||||||
<div className={`bg-white border rounded-lg p-4 hover:shadow-md transition-shadow ${className}`}>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center space-x-3">
|
|
||||||
<div className={`w-10 h-10 rounded-lg flex items-center justify-center ${
|
|
||||||
item.product_type === 'ingredient' ? 'bg-blue-100' : 'bg-green-100'
|
|
||||||
}`}>
|
|
||||||
<Package className={`w-5 h-5 ${
|
|
||||||
item.product_type === 'ingredient' ? 'text-blue-600' : 'text-green-600'
|
|
||||||
}`} />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h4 className="font-medium text-gray-900">{item.name}</h4>
|
|
||||||
<p className="text-sm text-gray-500">{getCategoryInfo()}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
{stockLevel && (
|
|
||||||
<div className="text-right">
|
|
||||||
<div className="text-sm font-medium text-gray-900">
|
|
||||||
{stockLevel.current_quantity} {stockLevel.unit_of_measure}
|
|
||||||
</div>
|
|
||||||
{stockStatus && (
|
|
||||||
<div className={`text-xs px-2 py-1 rounded-full ${
|
|
||||||
stockStatus.color === 'red' ? 'bg-red-100 text-red-800' :
|
|
||||||
stockStatus.color === 'yellow' ? 'bg-yellow-100 text-yellow-800' :
|
|
||||||
stockStatus.color === 'orange' ? 'bg-orange-100 text-orange-800' :
|
|
||||||
'bg-green-100 text-green-800'
|
|
||||||
}`}>
|
|
||||||
{stockStatus.label}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{showActions && onViewDetails && (
|
|
||||||
<button
|
|
||||||
onClick={() => onViewDetails(item)}
|
|
||||||
className="p-1 hover:bg-gray-100 rounded"
|
|
||||||
>
|
|
||||||
<Eye className="w-4 h-4 text-gray-400" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={`bg-white border rounded-xl shadow-sm hover:shadow-md transition-all ${className}`}>
|
|
||||||
{/* Header */}
|
|
||||||
<div className="p-6 pb-4">
|
|
||||||
<div className="flex items-start justify-between">
|
|
||||||
<div className="flex items-start space-x-4">
|
|
||||||
<div className={`w-12 h-12 rounded-xl flex items-center justify-center ${
|
|
||||||
item.product_type === 'ingredient' ? 'bg-blue-100' : 'bg-green-100'
|
|
||||||
}`}>
|
|
||||||
<Package className={`w-6 h-6 ${
|
|
||||||
item.product_type === 'ingredient' ? 'text-blue-600' : 'text-green-600'
|
|
||||||
}`} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex-1">
|
|
||||||
<div className="flex items-center space-x-2 mb-1">
|
|
||||||
<h3 className="text-lg font-semibold text-gray-900">{item.name}</h3>
|
|
||||||
{!item.is_active && (
|
|
||||||
<span className="px-2 py-1 bg-gray-100 text-gray-600 text-xs rounded-full">
|
|
||||||
Inactivo
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center space-x-4 text-sm text-gray-600">
|
|
||||||
<span className={`px-2 py-1 rounded-full text-xs ${
|
|
||||||
item.product_type === 'ingredient' ? 'bg-blue-100 text-blue-800' : 'bg-green-100 text-green-800'
|
|
||||||
}`}>
|
|
||||||
{item.product_type === 'ingredient' ? 'Ingrediente' : 'Producto Final'}
|
|
||||||
</span>
|
|
||||||
<span>{getCategoryInfo()}</span>
|
|
||||||
<span>{item.unit_of_measure}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Special requirements */}
|
|
||||||
{(item.requires_refrigeration || item.requires_freezing || item.is_seasonal) && (
|
|
||||||
<div className="flex items-center space-x-2 mt-2">
|
|
||||||
{item.requires_refrigeration && (
|
|
||||||
<div className="flex items-center space-x-1 text-xs text-blue-600">
|
|
||||||
<Thermometer className="w-3 h-3" />
|
|
||||||
<span>Refrigeración</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{item.requires_freezing && (
|
|
||||||
<div className="flex items-center space-x-1 text-xs text-blue-600">
|
|
||||||
<Snowflake className="w-3 h-3" />
|
|
||||||
<span>Congelación</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{item.is_seasonal && (
|
|
||||||
<div className="flex items-center space-x-1 text-xs text-amber-600">
|
|
||||||
<Calendar className="w-3 h-3" />
|
|
||||||
<span>Estacional</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{showActions && (
|
|
||||||
<div className="flex items-center space-x-1">
|
|
||||||
{onEdit && (
|
|
||||||
<button
|
|
||||||
onClick={() => onEdit(item)}
|
|
||||||
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
|
|
||||||
title="Editar"
|
|
||||||
>
|
|
||||||
<Edit3 className="w-4 h-4 text-gray-600" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{onViewDetails && (
|
|
||||||
<button
|
|
||||||
onClick={() => onViewDetails(item)}
|
|
||||||
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
|
|
||||||
title="Ver detalles"
|
|
||||||
>
|
|
||||||
<Eye className="w-4 h-4 text-gray-600" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<button className="p-2 hover:bg-gray-100 rounded-lg transition-colors">
|
|
||||||
<MoreVertical className="w-4 h-4 text-gray-600" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Stock Information */}
|
|
||||||
{stockLevel && (
|
|
||||||
<div className="px-6 pb-4">
|
|
||||||
<div className="flex items-center justify-between mb-3">
|
|
||||||
<h4 className="text-sm font-medium text-gray-700">Stock Actual</h4>
|
|
||||||
|
|
||||||
{(stockStatus || expirationStatus) && (
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
{expirationStatus && (
|
|
||||||
<div className={`flex items-center space-x-1 px-2 py-1 rounded-full text-xs ${
|
|
||||||
expirationStatus.color === 'red' ? 'bg-red-100 text-red-800' : 'bg-yellow-100 text-yellow-800'
|
|
||||||
}`}>
|
|
||||||
<Clock className="w-3 h-3" />
|
|
||||||
<span>{expirationStatus.label}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{stockStatus && (
|
|
||||||
<div className={`flex items-center space-x-1 px-2 py-1 rounded-full text-xs ${
|
|
||||||
stockStatus.color === 'red' ? 'bg-red-100 text-red-800' :
|
|
||||||
stockStatus.color === 'yellow' ? 'bg-yellow-100 text-yellow-800' :
|
|
||||||
stockStatus.color === 'orange' ? 'bg-orange-100 text-orange-800' :
|
|
||||||
'bg-green-100 text-green-800'
|
|
||||||
}`}>
|
|
||||||
{stockStatus.color === 'red' ? <AlertTriangle className="w-3 h-3" /> :
|
|
||||||
stockStatus.color === 'green' ? <TrendingUp className="w-3 h-3" /> :
|
|
||||||
<TrendingDown className="w-3 h-3" />}
|
|
||||||
<span>{stockStatus.label}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-3 gap-4 mb-4">
|
|
||||||
<div>
|
|
||||||
<div className="text-2xl font-bold text-gray-900">
|
|
||||||
{stockLevel.current_quantity}
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-gray-500">Cantidad Total</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="text-2xl font-bold text-green-600">
|
|
||||||
{stockLevel.available_quantity}
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-gray-500">Disponible</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="text-2xl font-bold text-amber-600">
|
|
||||||
{stockLevel.reserved_quantity}
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-gray-500">Reservado</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Stock Levels */}
|
|
||||||
{(item.minimum_stock_level || item.reorder_point) && (
|
|
||||||
<div className="flex items-center justify-between text-sm text-gray-600 mb-4">
|
|
||||||
{item.minimum_stock_level && (
|
|
||||||
<span>Mínimo: {item.minimum_stock_level}</span>
|
|
||||||
)}
|
|
||||||
{item.reorder_point && (
|
|
||||||
<span>Reorden: {item.reorder_point}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Quick Adjust */}
|
|
||||||
{showActions && onStockAdjust && (
|
|
||||||
<div className="border-t pt-4">
|
|
||||||
{!showQuickAdjust ? (
|
|
||||||
<button
|
|
||||||
onClick={() => setShowQuickAdjust(true)}
|
|
||||||
className="w-full px-4 py-2 bg-blue-50 text-blue-600 rounded-lg hover:bg-blue-100 transition-colors text-sm font-medium"
|
|
||||||
>
|
|
||||||
Ajustar Stock
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
value={adjustmentQuantity}
|
|
||||||
onChange={(e) => setAdjustmentQuantity(e.target.value)}
|
|
||||||
placeholder="Cantidad"
|
|
||||||
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
|
||||||
/>
|
|
||||||
<span className="text-sm text-gray-500">{item.unit_of_measure}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex space-x-2">
|
|
||||||
<button
|
|
||||||
onClick={() => handleQuickAdjust('add')}
|
|
||||||
disabled={!adjustmentQuantity}
|
|
||||||
className="flex-1 flex items-center justify-center space-x-1 px-3 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
||||||
>
|
|
||||||
<Plus className="w-4 h-4" />
|
|
||||||
<span>Agregar</span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => handleQuickAdjust('remove')}
|
|
||||||
disabled={!adjustmentQuantity}
|
|
||||||
className="flex-1 flex items-center justify-center space-x-1 px-3 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
||||||
>
|
|
||||||
<Minus className="w-4 h-4" />
|
|
||||||
<span>Consumir</span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
setShowQuickAdjust(false);
|
|
||||||
setAdjustmentQuantity('');
|
|
||||||
}}
|
|
||||||
className="px-3 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50"
|
|
||||||
>
|
|
||||||
Cancelar
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* No Stock Data */}
|
|
||||||
{!stockLevel && (
|
|
||||||
<div className="px-6 pb-6">
|
|
||||||
<div className="text-center py-4 border-2 border-dashed border-gray-200 rounded-lg">
|
|
||||||
<Package className="w-8 h-8 text-gray-400 mx-auto mb-2" />
|
|
||||||
<p className="text-sm text-gray-500">No hay datos de stock</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default InventoryItemCard;
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { Outlet } from 'react-router-dom';
|
|
||||||
import { SecondaryNavigation } from '../navigation/SecondaryNavigation';
|
|
||||||
import { Breadcrumbs } from '../navigation/Breadcrumbs';
|
|
||||||
import { useBakeryType } from '../../hooks/useBakeryType';
|
|
||||||
|
|
||||||
const AnalyticsLayout: React.FC = () => {
|
|
||||||
const { bakeryType } = useBakeryType();
|
|
||||||
|
|
||||||
const navigationItems = [
|
|
||||||
{
|
|
||||||
id: 'forecasting',
|
|
||||||
label: 'Predicciones',
|
|
||||||
href: '/app/analytics/forecasting',
|
|
||||||
icon: 'TrendingUp'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'sales-analytics',
|
|
||||||
label: 'Análisis Ventas',
|
|
||||||
href: '/app/analytics/sales-analytics',
|
|
||||||
icon: 'BarChart3'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'production-reports',
|
|
||||||
label: bakeryType === 'individual' ? 'Reportes Producción' : 'Reportes Distribución',
|
|
||||||
href: '/app/analytics/production-reports',
|
|
||||||
icon: 'FileBarChart'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'financial-reports',
|
|
||||||
label: 'Reportes Financieros',
|
|
||||||
href: '/app/analytics/financial-reports',
|
|
||||||
icon: 'DollarSign'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'performance-kpis',
|
|
||||||
label: 'KPIs Rendimiento',
|
|
||||||
href: '/app/analytics/performance-kpis',
|
|
||||||
icon: 'Target'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'ai-insights',
|
|
||||||
label: 'Insights IA',
|
|
||||||
href: '/app/analytics/ai-insights',
|
|
||||||
icon: 'Brain'
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col h-full">
|
|
||||||
<div className="bg-white border-b border-gray-200">
|
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
|
||||||
<Breadcrumbs />
|
|
||||||
<SecondaryNavigation items={navigationItems} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex-1 bg-gray-50">
|
|
||||||
<div className="max-w-7xl mx-auto">
|
|
||||||
<Outlet />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default AnalyticsLayout;
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { Outlet } from 'react-router-dom';
|
|
||||||
|
|
||||||
const AuthLayout: React.FC = () => {
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-gray-50">
|
|
||||||
<Outlet />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default AuthLayout;
|
|
||||||
@@ -1,243 +0,0 @@
|
|||||||
import React, { useState } from 'react';
|
|
||||||
import { Outlet, Link, useLocation, useNavigate } from 'react-router-dom';
|
|
||||||
import {
|
|
||||||
Home,
|
|
||||||
TrendingUp,
|
|
||||||
Package,
|
|
||||||
Settings,
|
|
||||||
Menu,
|
|
||||||
X,
|
|
||||||
LogOut,
|
|
||||||
User,
|
|
||||||
Bell,
|
|
||||||
ChevronDown,
|
|
||||||
BarChart3,
|
|
||||||
Building
|
|
||||||
} from 'lucide-react';
|
|
||||||
import { useSelector, useDispatch } from 'react-redux';
|
|
||||||
import { RootState } from '../../store';
|
|
||||||
import { logout } from '../../store/slices/authSlice';
|
|
||||||
import { TenantSelector } from '../navigation/TenantSelector';
|
|
||||||
import { usePermissions } from '../../hooks/usePermissions';
|
|
||||||
|
|
||||||
interface LayoutProps {
|
|
||||||
// No props needed - using React Router
|
|
||||||
}
|
|
||||||
|
|
||||||
interface NavigationItem {
|
|
||||||
id: string;
|
|
||||||
label: string;
|
|
||||||
icon: React.ComponentType<{ className?: string }>;
|
|
||||||
href: string;
|
|
||||||
requiresRole?: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
const Layout: React.FC<LayoutProps> = () => {
|
|
||||||
const location = useLocation();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const dispatch = useDispatch();
|
|
||||||
const { user } = useSelector((state: RootState) => state.auth);
|
|
||||||
const { hasRole } = usePermissions();
|
|
||||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
|
||||||
const [isUserMenuOpen, setIsUserMenuOpen] = useState(false);
|
|
||||||
|
|
||||||
const navigation: NavigationItem[] = [
|
|
||||||
{ id: 'dashboard', label: 'Dashboard', icon: Home, href: '/app/dashboard' },
|
|
||||||
{ id: 'operations', label: 'Operaciones', icon: Package, href: '/app/operations' },
|
|
||||||
{
|
|
||||||
id: 'analytics',
|
|
||||||
label: 'Analytics',
|
|
||||||
icon: BarChart3,
|
|
||||||
href: '/app/analytics',
|
|
||||||
requiresRole: ['admin', 'manager']
|
|
||||||
},
|
|
||||||
{ id: 'settings', label: 'Configuración', icon: Settings, href: '/app/settings' },
|
|
||||||
];
|
|
||||||
|
|
||||||
// Filter navigation based on user role
|
|
||||||
const filteredNavigation = navigation.filter(item => {
|
|
||||||
if (!item.requiresRole) return true;
|
|
||||||
return item.requiresRole.some(role => hasRole(role));
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleLogout = () => {
|
|
||||||
if (window.confirm('¿Estás seguro de que quieres cerrar sesión?')) {
|
|
||||||
dispatch(logout());
|
|
||||||
localStorage.removeItem('auth_token');
|
|
||||||
localStorage.removeItem('user_data');
|
|
||||||
localStorage.removeItem('selectedTenantId');
|
|
||||||
navigate('/');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const isActiveRoute = (href: string): boolean => {
|
|
||||||
if (href === '/app/dashboard') {
|
|
||||||
return location.pathname === '/app/dashboard' || location.pathname === '/app';
|
|
||||||
}
|
|
||||||
return location.pathname.startsWith(href);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-gray-50">
|
|
||||||
{/* Top Navigation Bar */}
|
|
||||||
<nav className="bg-white shadow-soft border-b border-gray-200">
|
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
|
||||||
<div className="flex justify-between h-16">
|
|
||||||
{/* Left side - Logo and Navigation */}
|
|
||||||
<div className="flex items-center">
|
|
||||||
{/* Mobile menu button */}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="md:hidden p-2 rounded-md text-gray-600 hover:text-gray-900 hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-primary-500"
|
|
||||||
onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}
|
|
||||||
>
|
|
||||||
{isMobileMenuOpen ? (
|
|
||||||
<X className="h-6 w-6" />
|
|
||||||
) : (
|
|
||||||
<Menu className="h-6 w-6" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Logo */}
|
|
||||||
<div className="flex items-center ml-4 md:ml-0">
|
|
||||||
<div className="h-8 w-8 bg-primary-500 rounded-lg flex items-center justify-center mr-3">
|
|
||||||
<span className="text-white text-sm font-bold">🥖</span>
|
|
||||||
</div>
|
|
||||||
<span className="text-xl font-bold text-gray-900">PanIA</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Desktop Navigation */}
|
|
||||||
<div className="hidden md:flex md:ml-10 md:space-x-1">
|
|
||||||
{filteredNavigation.map((item) => {
|
|
||||||
const Icon = item.icon;
|
|
||||||
const isActive = isActiveRoute(item.href);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Link
|
|
||||||
key={item.id}
|
|
||||||
to={item.href}
|
|
||||||
className={`
|
|
||||||
flex items-center px-3 py-2 rounded-lg text-sm font-medium transition-all duration-200
|
|
||||||
${isActive
|
|
||||||
? 'bg-primary-100 text-primary-700 shadow-soft'
|
|
||||||
: 'text-gray-600 hover:text-gray-900 hover:bg-gray-100'
|
|
||||||
}
|
|
||||||
`}
|
|
||||||
onClick={() => setIsMobileMenuOpen(false)}
|
|
||||||
>
|
|
||||||
<Icon className="h-4 w-4 mr-2" />
|
|
||||||
{item.label}
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Right side - Tenant Selector, Notifications and User Menu */}
|
|
||||||
<div className="flex items-center space-x-4">
|
|
||||||
{/* Tenant Selector */}
|
|
||||||
<TenantSelector />
|
|
||||||
{/* Notifications */}
|
|
||||||
<button className="p-2 text-gray-600 hover:text-gray-900 hover:bg-gray-100 rounded-lg transition-colors relative">
|
|
||||||
<Bell className="h-5 w-5" />
|
|
||||||
<span className="absolute top-0 right-0 h-2 w-2 bg-red-500 rounded-full"></span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* User Menu */}
|
|
||||||
<div className="relative">
|
|
||||||
<button
|
|
||||||
onClick={() => setIsUserMenuOpen(!isUserMenuOpen)}
|
|
||||||
className="flex items-center text-sm bg-white rounded-lg p-2 hover:bg-gray-50 transition-colors focus:outline-none focus:ring-2 focus:ring-primary-500"
|
|
||||||
>
|
|
||||||
<div className="h-8 w-8 bg-primary-500 rounded-full flex items-center justify-center mr-2">
|
|
||||||
<User className="h-4 w-4 text-white" />
|
|
||||||
</div>
|
|
||||||
<span className="hidden md:block text-gray-700 font-medium">
|
|
||||||
{user.fullName?.split(' ')[0] || 'Usuario'}
|
|
||||||
</span>
|
|
||||||
<ChevronDown className="hidden md:block h-4 w-4 ml-1 text-gray-500" />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* User Dropdown */}
|
|
||||||
{isUserMenuOpen && (
|
|
||||||
<div className="absolute right-0 mt-2 w-48 bg-white rounded-xl shadow-strong border border-gray-200 py-1 z-50">
|
|
||||||
<div className="px-4 py-3 border-b border-gray-100">
|
|
||||||
<p className="text-sm font-medium text-gray-900">{user.fullName}</p>
|
|
||||||
<p className="text-sm text-gray-500">{user.email}</p>
|
|
||||||
</div>
|
|
||||||
<Link
|
|
||||||
to="/app/settings"
|
|
||||||
onClick={() => setIsUserMenuOpen(false)}
|
|
||||||
className="w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-50 flex items-center"
|
|
||||||
>
|
|
||||||
<Settings className="h-4 w-4 mr-2" />
|
|
||||||
Configuración
|
|
||||||
</Link>
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
handleLogout();
|
|
||||||
setIsUserMenuOpen(false);
|
|
||||||
}}
|
|
||||||
className="w-full text-left px-4 py-2 text-sm text-red-600 hover:bg-red-50 flex items-center"
|
|
||||||
>
|
|
||||||
<LogOut className="h-4 w-4 mr-2" />
|
|
||||||
Cerrar sesión
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Mobile Navigation Menu */}
|
|
||||||
{isMobileMenuOpen && (
|
|
||||||
<div className="md:hidden border-t border-gray-200 bg-white">
|
|
||||||
<div className="px-2 pt-2 pb-3 space-y-1">
|
|
||||||
{filteredNavigation.map((item) => {
|
|
||||||
const Icon = item.icon;
|
|
||||||
const isActive = isActiveRoute(item.href);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Link
|
|
||||||
key={item.id}
|
|
||||||
to={item.href}
|
|
||||||
onClick={() => setIsMobileMenuOpen(false)}
|
|
||||||
className={`
|
|
||||||
w-full flex items-center px-3 py-2 rounded-lg text-base font-medium transition-all duration-200
|
|
||||||
${isActive
|
|
||||||
? 'bg-primary-100 text-primary-700'
|
|
||||||
: 'text-gray-600 hover:text-gray-900 hover:bg-gray-100'
|
|
||||||
}
|
|
||||||
`}
|
|
||||||
>
|
|
||||||
<Icon className="h-5 w-5 mr-3" />
|
|
||||||
{item.label}
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
{/* Main Content */}
|
|
||||||
<main className="flex-1">
|
|
||||||
<Outlet />
|
|
||||||
</main>
|
|
||||||
|
|
||||||
{/* Click outside handler for dropdowns */}
|
|
||||||
{(isUserMenuOpen || isMobileMenuOpen) && (
|
|
||||||
<div
|
|
||||||
className="fixed inset-0 z-40"
|
|
||||||
onClick={() => {
|
|
||||||
setIsUserMenuOpen(false);
|
|
||||||
setIsMobileMenuOpen(false);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Layout;
|
|
||||||
@@ -1,109 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { Outlet } from 'react-router-dom';
|
|
||||||
import { SecondaryNavigation } from '../navigation/SecondaryNavigation';
|
|
||||||
import { Breadcrumbs } from '../navigation/Breadcrumbs';
|
|
||||||
import { useBakeryType } from '../../hooks/useBakeryType';
|
|
||||||
|
|
||||||
const OperationsLayout: React.FC = () => {
|
|
||||||
const { bakeryType } = useBakeryType();
|
|
||||||
|
|
||||||
// Define navigation items based on bakery type
|
|
||||||
const getNavigationItems = () => {
|
|
||||||
const baseItems = [
|
|
||||||
{
|
|
||||||
id: 'production',
|
|
||||||
label: bakeryType === 'individual' ? 'Producción' : 'Distribución',
|
|
||||||
href: '/app/operations/production',
|
|
||||||
icon: 'ChefHat',
|
|
||||||
children: bakeryType === 'individual' ? [
|
|
||||||
{ id: 'schedule', label: 'Programación', href: '/app/operations/production/schedule' },
|
|
||||||
{ id: 'active-batches', label: 'Lotes Activos', href: '/app/operations/production/active-batches' },
|
|
||||||
{ id: 'equipment', label: 'Equipamiento', href: '/app/operations/production/equipment' }
|
|
||||||
] : [
|
|
||||||
{ id: 'schedule', label: 'Distribución', href: '/app/operations/production/schedule' },
|
|
||||||
{ id: 'active-batches', label: 'Asignaciones', href: '/app/operations/production/active-batches' },
|
|
||||||
{ id: 'equipment', label: 'Logística', href: '/app/operations/production/equipment' }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'orders',
|
|
||||||
label: 'Pedidos',
|
|
||||||
href: '/app/operations/orders',
|
|
||||||
icon: 'Package',
|
|
||||||
children: [
|
|
||||||
{ id: 'incoming', label: bakeryType === 'individual' ? 'Entrantes' : 'Puntos de Venta', href: '/app/operations/orders/incoming' },
|
|
||||||
{ id: 'in-progress', label: 'En Proceso', href: '/app/operations/orders/in-progress' },
|
|
||||||
{ id: 'supplier-orders', label: bakeryType === 'individual' ? 'Proveedores' : 'Productos', href: '/app/operations/orders/supplier-orders' }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'inventory',
|
|
||||||
label: 'Inventario',
|
|
||||||
href: '/app/operations/inventory',
|
|
||||||
icon: 'Warehouse',
|
|
||||||
children: [
|
|
||||||
{ id: 'stock-levels', label: bakeryType === 'individual' ? 'Ingredientes' : 'Productos', href: '/app/operations/inventory/stock-levels' },
|
|
||||||
{ id: 'movements', label: bakeryType === 'individual' ? 'Uso' : 'Distribución', href: '/app/operations/inventory/movements' },
|
|
||||||
{ id: 'alerts', label: bakeryType === 'individual' ? 'Caducidad' : 'Retrasos', href: '/app/operations/inventory/alerts' }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'sales',
|
|
||||||
label: 'Ventas',
|
|
||||||
href: '/app/operations/sales',
|
|
||||||
icon: 'ShoppingCart',
|
|
||||||
children: [
|
|
||||||
{ id: 'daily-sales', label: 'Ventas Diarias', href: '/app/operations/sales/daily-sales' },
|
|
||||||
{ id: 'customer-orders', label: bakeryType === 'individual' ? 'Pedidos Cliente' : 'Pedidos Punto', href: '/app/operations/sales/customer-orders' }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'pos',
|
|
||||||
label: bakeryType === 'individual' ? 'TPV' : 'Sistema TPV',
|
|
||||||
href: '/app/operations/pos',
|
|
||||||
icon: 'CreditCard',
|
|
||||||
children: [
|
|
||||||
{ id: 'integrations', label: 'Integraciones', href: '/app/operations/pos/integrations' },
|
|
||||||
{ id: 'sync-status', label: 'Estado Sincronización', href: '/app/operations/pos/sync-status' },
|
|
||||||
{ id: 'transactions', label: 'Transacciones', href: '/app/operations/pos/transactions' }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
// Add recipes for individual bakeries, hide for central
|
|
||||||
if (bakeryType === 'individual') {
|
|
||||||
baseItems.push({
|
|
||||||
id: 'recipes',
|
|
||||||
label: 'Recetas',
|
|
||||||
href: '/app/operations/recipes',
|
|
||||||
icon: 'BookOpen',
|
|
||||||
children: [
|
|
||||||
{ id: 'active-recipes', label: 'Recetas Activas', href: '/app/operations/recipes/active-recipes' },
|
|
||||||
{ id: 'development', label: 'Desarrollo', href: '/app/operations/recipes/development' },
|
|
||||||
{ id: 'costing', label: 'Costeo', href: '/app/operations/recipes/costing' }
|
|
||||||
]
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return baseItems;
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col h-full">
|
|
||||||
<div className="bg-white border-b border-gray-200">
|
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
|
||||||
<Breadcrumbs />
|
|
||||||
<SecondaryNavigation items={getNavigationItems()} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex-1 bg-gray-50">
|
|
||||||
<div className="max-w-7xl mx-auto">
|
|
||||||
<Outlet />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default OperationsLayout;
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { Outlet } from 'react-router-dom';
|
|
||||||
import { SecondaryNavigation } from '../navigation/SecondaryNavigation';
|
|
||||||
import { Breadcrumbs } from '../navigation/Breadcrumbs';
|
|
||||||
import { usePermissions } from '../../hooks/usePermissions';
|
|
||||||
|
|
||||||
const SettingsLayout: React.FC = () => {
|
|
||||||
const { hasRole } = usePermissions();
|
|
||||||
|
|
||||||
const getNavigationItems = () => {
|
|
||||||
const baseItems = [
|
|
||||||
{
|
|
||||||
id: 'general',
|
|
||||||
label: 'General',
|
|
||||||
href: '/app/settings/general',
|
|
||||||
icon: 'Settings'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'account',
|
|
||||||
label: 'Cuenta',
|
|
||||||
href: '/app/settings/account',
|
|
||||||
icon: 'User'
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
// Add admin-only items
|
|
||||||
if (hasRole('admin')) {
|
|
||||||
baseItems.unshift(
|
|
||||||
{
|
|
||||||
id: 'bakeries',
|
|
||||||
label: 'Panaderías',
|
|
||||||
href: '/app/settings/bakeries',
|
|
||||||
icon: 'Building'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'users',
|
|
||||||
label: 'Usuarios',
|
|
||||||
href: '/app/settings/users',
|
|
||||||
icon: 'Users'
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return baseItems;
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col h-full">
|
|
||||||
<div className="bg-white border-b border-gray-200">
|
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
|
||||||
<Breadcrumbs />
|
|
||||||
<SecondaryNavigation items={getNavigationItems()} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex-1 bg-gray-50">
|
|
||||||
<div className="max-w-7xl mx-auto">
|
|
||||||
<Outlet />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default SettingsLayout;
|
|
||||||
@@ -1,123 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { Link, useLocation } from 'react-router-dom';
|
|
||||||
import { ChevronRight, Home } from 'lucide-react';
|
|
||||||
|
|
||||||
interface BreadcrumbItem {
|
|
||||||
label: string;
|
|
||||||
href?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const Breadcrumbs: React.FC = () => {
|
|
||||||
const location = useLocation();
|
|
||||||
|
|
||||||
const getBreadcrumbs = (): BreadcrumbItem[] => {
|
|
||||||
const pathSegments = location.pathname.split('/').filter(Boolean);
|
|
||||||
|
|
||||||
// Remove 'app' from the beginning if present
|
|
||||||
if (pathSegments[0] === 'app') {
|
|
||||||
pathSegments.shift();
|
|
||||||
}
|
|
||||||
|
|
||||||
const breadcrumbs: BreadcrumbItem[] = [
|
|
||||||
{ label: 'Inicio', href: '/app/dashboard' }
|
|
||||||
];
|
|
||||||
|
|
||||||
const segmentMap: Record<string, string> = {
|
|
||||||
// Main sections
|
|
||||||
'dashboard': 'Dashboard',
|
|
||||||
'operations': 'Operaciones',
|
|
||||||
'analytics': 'Analytics',
|
|
||||||
'settings': 'Configuración',
|
|
||||||
|
|
||||||
// Operations subsections
|
|
||||||
'production': 'Producción',
|
|
||||||
'orders': 'Pedidos',
|
|
||||||
'inventory': 'Inventario',
|
|
||||||
'sales': 'Ventas',
|
|
||||||
'recipes': 'Recetas',
|
|
||||||
|
|
||||||
// Operations sub-pages
|
|
||||||
'schedule': 'Programación',
|
|
||||||
'active-batches': 'Lotes Activos',
|
|
||||||
'equipment': 'Equipamiento',
|
|
||||||
'incoming': 'Entrantes',
|
|
||||||
'in-progress': 'En Proceso',
|
|
||||||
'supplier-orders': 'Proveedores',
|
|
||||||
'stock-levels': 'Niveles Stock',
|
|
||||||
'movements': 'Movimientos',
|
|
||||||
'alerts': 'Alertas',
|
|
||||||
'daily-sales': 'Ventas Diarias',
|
|
||||||
'customer-orders': 'Pedidos Cliente',
|
|
||||||
'pos-integration': 'Integración TPV',
|
|
||||||
'active-recipes': 'Recetas Activas',
|
|
||||||
'development': 'Desarrollo',
|
|
||||||
'costing': 'Costeo',
|
|
||||||
|
|
||||||
// Analytics subsections
|
|
||||||
'forecasting': 'Predicciones',
|
|
||||||
'sales-analytics': 'Análisis Ventas',
|
|
||||||
'production-reports': 'Reportes Producción',
|
|
||||||
'financial-reports': 'Reportes Financieros',
|
|
||||||
'performance-kpis': 'KPIs',
|
|
||||||
'ai-insights': 'Insights IA',
|
|
||||||
|
|
||||||
// Settings subsections
|
|
||||||
'general': 'General',
|
|
||||||
'users': 'Usuarios',
|
|
||||||
'bakeries': 'Panaderías',
|
|
||||||
'account': 'Cuenta'
|
|
||||||
};
|
|
||||||
|
|
||||||
let currentPath = '/app';
|
|
||||||
|
|
||||||
pathSegments.forEach((segment, index) => {
|
|
||||||
currentPath += `/${segment}`;
|
|
||||||
const label = segmentMap[segment] || segment.charAt(0).toUpperCase() + segment.slice(1);
|
|
||||||
|
|
||||||
// Don't make the last item clickable
|
|
||||||
const isLast = index === pathSegments.length - 1;
|
|
||||||
|
|
||||||
breadcrumbs.push({
|
|
||||||
label,
|
|
||||||
href: isLast ? undefined : currentPath
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
return breadcrumbs;
|
|
||||||
};
|
|
||||||
|
|
||||||
const breadcrumbs = getBreadcrumbs();
|
|
||||||
|
|
||||||
if (breadcrumbs.length <= 1) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<nav className="flex items-center space-x-2 py-3 text-sm" aria-label="Breadcrumb">
|
|
||||||
<ol className="flex items-center space-x-2">
|
|
||||||
{breadcrumbs.map((breadcrumb, index) => (
|
|
||||||
<li key={index} className="flex items-center">
|
|
||||||
{index > 0 && (
|
|
||||||
<ChevronRight className="h-4 w-4 text-gray-400 mx-2" />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{breadcrumb.href ? (
|
|
||||||
<Link
|
|
||||||
to={breadcrumb.href}
|
|
||||||
className="flex items-center text-gray-600 hover:text-gray-900 transition-colors"
|
|
||||||
>
|
|
||||||
{index === 0 && <Home className="h-4 w-4 mr-1" />}
|
|
||||||
{breadcrumb.label}
|
|
||||||
</Link>
|
|
||||||
) : (
|
|
||||||
<span className="flex items-center text-gray-900 font-medium">
|
|
||||||
{index === 0 && <Home className="h-4 w-4 mr-1" />}
|
|
||||||
{breadcrumb.label}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ol>
|
|
||||||
</nav>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,126 +0,0 @@
|
|||||||
import React, { useState } from 'react';
|
|
||||||
import { Link, useLocation } from 'react-router-dom';
|
|
||||||
import { ChevronDown, ChevronRight } from 'lucide-react';
|
|
||||||
import * as Icons from 'lucide-react';
|
|
||||||
|
|
||||||
interface NavigationChild {
|
|
||||||
id: string;
|
|
||||||
label: string;
|
|
||||||
href: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface NavigationItem {
|
|
||||||
id: string;
|
|
||||||
label: string;
|
|
||||||
href: string;
|
|
||||||
icon: string;
|
|
||||||
children?: NavigationChild[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SecondaryNavigationProps {
|
|
||||||
items: NavigationItem[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export const SecondaryNavigation: React.FC<SecondaryNavigationProps> = ({ items }) => {
|
|
||||||
const location = useLocation();
|
|
||||||
const [expandedItems, setExpandedItems] = useState<Set<string>>(new Set());
|
|
||||||
|
|
||||||
const toggleExpanded = (itemId: string) => {
|
|
||||||
const newExpanded = new Set(expandedItems);
|
|
||||||
if (newExpanded.has(itemId)) {
|
|
||||||
newExpanded.delete(itemId);
|
|
||||||
} else {
|
|
||||||
newExpanded.add(itemId);
|
|
||||||
}
|
|
||||||
setExpandedItems(newExpanded);
|
|
||||||
};
|
|
||||||
|
|
||||||
const isActive = (href: string): boolean => {
|
|
||||||
return location.pathname === href || location.pathname.startsWith(href + '/');
|
|
||||||
};
|
|
||||||
|
|
||||||
const hasActiveChild = (children?: NavigationChild[]): boolean => {
|
|
||||||
if (!children) return false;
|
|
||||||
return children.some(child => isActive(child.href));
|
|
||||||
};
|
|
||||||
|
|
||||||
// Auto-expand items with active children
|
|
||||||
React.useEffect(() => {
|
|
||||||
const itemsToExpand = new Set(expandedItems);
|
|
||||||
items.forEach(item => {
|
|
||||||
if (hasActiveChild(item.children)) {
|
|
||||||
itemsToExpand.add(item.id);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
setExpandedItems(itemsToExpand);
|
|
||||||
}, [location.pathname]);
|
|
||||||
|
|
||||||
const getIcon = (iconName: string) => {
|
|
||||||
const IconComponent = (Icons as any)[iconName];
|
|
||||||
return IconComponent || Icons.Circle;
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<nav className="border-b border-gray-200">
|
|
||||||
<div className="flex space-x-8 overflow-x-auto">
|
|
||||||
{items.map((item) => {
|
|
||||||
const Icon = getIcon(item.icon);
|
|
||||||
const isItemActive = isActive(item.href);
|
|
||||||
const hasChildren = item.children && item.children.length > 0;
|
|
||||||
const isExpanded = expandedItems.has(item.id);
|
|
||||||
const hasActiveChildItem = hasActiveChild(item.children);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div key={item.id} className="relative group">
|
|
||||||
<div className="flex items-center">
|
|
||||||
<Link
|
|
||||||
to={item.href}
|
|
||||||
className={`flex items-center px-4 py-4 text-sm font-medium border-b-2 transition-colors whitespace-nowrap ${
|
|
||||||
isItemActive || hasActiveChildItem
|
|
||||||
? 'border-primary-500 text-primary-600'
|
|
||||||
: 'border-transparent text-gray-600 hover:text-gray-900 hover:border-gray-300'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<Icon className="h-4 w-4 mr-2" />
|
|
||||||
{item.label}
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
{hasChildren && (
|
|
||||||
<button
|
|
||||||
onClick={() => toggleExpanded(item.id)}
|
|
||||||
className="ml-1 p-1 rounded hover:bg-gray-100 transition-colors"
|
|
||||||
>
|
|
||||||
{isExpanded ? (
|
|
||||||
<ChevronDown className="h-4 w-4 text-gray-500" />
|
|
||||||
) : (
|
|
||||||
<ChevronRight className="h-4 w-4 text-gray-500" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Dropdown for children */}
|
|
||||||
{hasChildren && isExpanded && (
|
|
||||||
<div className="absolute top-full left-0 mt-1 w-64 bg-white rounded-lg shadow-lg border border-gray-200 py-2 z-50">
|
|
||||||
{item.children!.map((child) => (
|
|
||||||
<Link
|
|
||||||
key={child.id}
|
|
||||||
to={child.href}
|
|
||||||
className={`block px-4 py-2 text-sm transition-colors ${
|
|
||||||
isActive(child.href)
|
|
||||||
? 'bg-primary-50 text-primary-700 border-r-2 border-primary-500'
|
|
||||||
: 'text-gray-700 hover:bg-gray-50'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{child.label}
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,170 +0,0 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
|
||||||
import { ChevronDown, Building, Check } from 'lucide-react';
|
|
||||||
import { useSelector, useDispatch } from 'react-redux';
|
|
||||||
import { RootState } from '../../store';
|
|
||||||
import { setCurrentTenant } from '../../store/slices/tenantSlice';
|
|
||||||
import { useTenant } from '../../api/hooks/useTenant';
|
|
||||||
import toast from 'react-hot-toast';
|
|
||||||
|
|
||||||
export const TenantSelector: React.FC = () => {
|
|
||||||
const dispatch = useDispatch();
|
|
||||||
const { currentTenant } = useSelector((state: RootState) => state.tenant);
|
|
||||||
const { user } = useSelector((state: RootState) => state.auth);
|
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
|
||||||
|
|
||||||
const {
|
|
||||||
tenants,
|
|
||||||
getUserTenants,
|
|
||||||
isLoading,
|
|
||||||
error
|
|
||||||
} = useTenant();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (user) {
|
|
||||||
getUserTenants();
|
|
||||||
}
|
|
||||||
}, [user, getUserTenants]);
|
|
||||||
|
|
||||||
// Auto-select tenant based on localStorage or default to first one
|
|
||||||
useEffect(() => {
|
|
||||||
if (Array.isArray(tenants) && tenants.length > 0 && !currentTenant) {
|
|
||||||
const savedTenantId = localStorage.getItem('selectedTenantId');
|
|
||||||
const tenantToSelect = savedTenantId
|
|
||||||
? tenants.find(t => t.id === savedTenantId) || tenants[0]
|
|
||||||
: tenants[0];
|
|
||||||
|
|
||||||
console.log('🎯 Auto-selecting tenant:', tenantToSelect);
|
|
||||||
dispatch(setCurrentTenant(tenantToSelect));
|
|
||||||
localStorage.setItem('selectedTenantId', tenantToSelect.id);
|
|
||||||
}
|
|
||||||
}, [tenants, currentTenant, dispatch]);
|
|
||||||
|
|
||||||
const handleTenantChange = async (tenant: any) => {
|
|
||||||
try {
|
|
||||||
dispatch(setCurrentTenant(tenant));
|
|
||||||
localStorage.setItem('selectedTenantId', tenant.id);
|
|
||||||
setIsOpen(false);
|
|
||||||
|
|
||||||
toast.success(`Cambiado a ${tenant.name}`);
|
|
||||||
|
|
||||||
// Force a page reload to update data with new tenant context
|
|
||||||
window.location.reload();
|
|
||||||
} catch (error) {
|
|
||||||
toast.error('Error al cambiar de panadería');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<div className="text-sm text-gray-500">
|
|
||||||
Cargando panaderías...
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show current tenant name even if there's only one
|
|
||||||
if (!Array.isArray(tenants) || tenants.length === 0) {
|
|
||||||
return (
|
|
||||||
<div className="text-sm text-gray-500">
|
|
||||||
No hay panaderías disponibles
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// If there's only one tenant, just show its name without dropdown
|
|
||||||
if (tenants.length === 1) {
|
|
||||||
const tenant = tenants[0];
|
|
||||||
return (
|
|
||||||
<div className="flex items-center text-sm">
|
|
||||||
<Building className="h-4 w-4 text-gray-400 mr-2" />
|
|
||||||
<span className="font-medium text-gray-900">{tenant.name}</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="relative">
|
|
||||||
<button
|
|
||||||
onClick={() => setIsOpen(!isOpen)}
|
|
||||||
className="flex items-center text-sm bg-white rounded-lg p-2 hover:bg-gray-50 transition-colors focus:outline-none focus:ring-2 focus:ring-primary-500 border border-gray-200"
|
|
||||||
>
|
|
||||||
<Building className="h-4 w-4 text-gray-600 mr-2" />
|
|
||||||
<span className="text-gray-700 font-medium max-w-32 truncate">
|
|
||||||
{currentTenant?.name || 'Seleccionar panadería'}
|
|
||||||
</span>
|
|
||||||
<ChevronDown className="h-4 w-4 ml-1 text-gray-500" />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{isOpen && (
|
|
||||||
<>
|
|
||||||
{/* Backdrop */}
|
|
||||||
<div
|
|
||||||
className="fixed inset-0 z-40"
|
|
||||||
onClick={() => setIsOpen(false)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Dropdown */}
|
|
||||||
<div className="absolute right-0 mt-2 w-64 bg-white rounded-xl shadow-strong border border-gray-200 py-2 z-50">
|
|
||||||
<div className="px-4 py-2 border-b border-gray-100">
|
|
||||||
<p className="text-xs font-medium text-gray-500 uppercase tracking-wide">
|
|
||||||
Mis Panaderías
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="max-h-64 overflow-y-auto">
|
|
||||||
{Array.isArray(tenants) ? tenants.map((tenant) => (
|
|
||||||
<button
|
|
||||||
key={tenant.id}
|
|
||||||
onClick={() => handleTenantChange(tenant)}
|
|
||||||
className="w-full text-left px-4 py-3 text-sm hover:bg-gray-50 flex items-center justify-between transition-colors"
|
|
||||||
>
|
|
||||||
<div className="flex items-center min-w-0">
|
|
||||||
<Building className="h-4 w-4 text-gray-400 mr-3 flex-shrink-0" />
|
|
||||||
<div className="min-w-0">
|
|
||||||
<p className="font-medium text-gray-900 truncate">
|
|
||||||
{tenant.name}
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-gray-500 truncate">
|
|
||||||
{tenant.address}
|
|
||||||
</p>
|
|
||||||
<div className="flex items-center mt-1">
|
|
||||||
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${
|
|
||||||
tenant.business_type === 'individual'
|
|
||||||
? 'bg-blue-100 text-blue-800'
|
|
||||||
: 'bg-purple-100 text-purple-800'
|
|
||||||
}`}>
|
|
||||||
{tenant.business_type === 'individual' ? 'Individual' : 'Obrador Central'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{currentTenant?.id === tenant.id && (
|
|
||||||
<Check className="h-4 w-4 text-primary-600 flex-shrink-0" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
)) : (
|
|
||||||
<div className="px-4 py-3 text-sm text-gray-500">
|
|
||||||
No hay panaderías disponibles
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="px-4 py-2 border-t border-gray-100">
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
setIsOpen(false);
|
|
||||||
// Navigate to bakeries management
|
|
||||||
window.location.href = '/app/settings/bakeries';
|
|
||||||
}}
|
|
||||||
className="text-xs text-primary-600 hover:text-primary-700 font-medium"
|
|
||||||
>
|
|
||||||
+ Administrar panaderías
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,891 +0,0 @@
|
|||||||
import React, { useState, useCallback } from 'react';
|
|
||||||
import {
|
|
||||||
Upload,
|
|
||||||
Brain,
|
|
||||||
Check,
|
|
||||||
AlertTriangle,
|
|
||||||
Loader,
|
|
||||||
Store,
|
|
||||||
Factory,
|
|
||||||
Settings2,
|
|
||||||
Package,
|
|
||||||
Coffee,
|
|
||||||
Wheat,
|
|
||||||
Eye,
|
|
||||||
EyeOff,
|
|
||||||
CheckCircle2,
|
|
||||||
XCircle,
|
|
||||||
ArrowRight,
|
|
||||||
Lightbulb,
|
|
||||||
Building2,
|
|
||||||
Truck
|
|
||||||
} from 'lucide-react';
|
|
||||||
import toast from 'react-hot-toast';
|
|
||||||
|
|
||||||
import {
|
|
||||||
FileValidationResult,
|
|
||||||
ProductSuggestionsResult,
|
|
||||||
OnboardingAnalysisResult,
|
|
||||||
InventorySuggestion,
|
|
||||||
BusinessModelAnalysis,
|
|
||||||
InventoryCreationResult,
|
|
||||||
SalesImportResult,
|
|
||||||
onboardingService
|
|
||||||
} from '../../api/services/onboarding.service';
|
|
||||||
|
|
||||||
interface SmartHistoricalDataImportProps {
|
|
||||||
tenantId: string;
|
|
||||||
onComplete: (result: SalesImportResult) => void;
|
|
||||||
onBack?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
type ImportPhase = 'upload' | 'validation' | 'suggestions' | 'review' | 'creation' | 'import' | 'complete';
|
|
||||||
|
|
||||||
interface PhaseState {
|
|
||||||
phase: ImportPhase;
|
|
||||||
file?: File;
|
|
||||||
validationResult?: FileValidationResult;
|
|
||||||
suggestionsResult?: ProductSuggestionsResult;
|
|
||||||
reviewedSuggestions?: InventorySuggestion[];
|
|
||||||
creationResult?: InventoryCreationResult;
|
|
||||||
importResult?: SalesImportResult;
|
|
||||||
error?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const SmartHistoricalDataImport: React.FC<SmartHistoricalDataImportProps> = ({
|
|
||||||
tenantId,
|
|
||||||
onComplete,
|
|
||||||
onBack
|
|
||||||
}) => {
|
|
||||||
const [state, setState] = useState<PhaseState>({ phase: 'upload' });
|
|
||||||
const [isProcessing, setIsProcessing] = useState(false);
|
|
||||||
const [showAllSuggestions, setShowAllSuggestions] = useState(false);
|
|
||||||
|
|
||||||
const handleFileUpload = useCallback(async (file: File) => {
|
|
||||||
setState(prev => ({ ...prev, file, phase: 'validation' }));
|
|
||||||
setIsProcessing(true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Step 1: Validate file and extract products
|
|
||||||
toast.loading('📋 Validando archivo...', { id: 'validation' });
|
|
||||||
|
|
||||||
const validationResult = await onboardingService.validateFileAndExtractProducts(tenantId, file);
|
|
||||||
|
|
||||||
if (!validationResult.is_valid) {
|
|
||||||
throw new Error(`Archivo inválido: ${validationResult.validation_errors.map(e => e.message || e).join(', ')}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
toast.success(`¡Archivo válido! ${validationResult.unique_products} productos únicos encontrados`, {
|
|
||||||
id: 'validation'
|
|
||||||
});
|
|
||||||
|
|
||||||
setState(prev => ({ ...prev, validationResult, phase: 'suggestions' }));
|
|
||||||
|
|
||||||
// Step 2: Generate AI suggestions
|
|
||||||
setTimeout(() => handleGenerateSuggestions(file, validationResult.product_list), 1000);
|
|
||||||
|
|
||||||
} catch (error: any) {
|
|
||||||
toast.error('Error al validar el archivo', { id: 'validation' });
|
|
||||||
setState(prev => ({
|
|
||||||
...prev,
|
|
||||||
error: error.message || 'Error de validación',
|
|
||||||
phase: 'upload'
|
|
||||||
}));
|
|
||||||
} finally {
|
|
||||||
setIsProcessing(false);
|
|
||||||
}
|
|
||||||
}, [tenantId]);
|
|
||||||
|
|
||||||
const handleGenerateSuggestions = useCallback(async (file: File, productList: string[]) => {
|
|
||||||
setIsProcessing(true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
toast.loading('🧠 Generando sugerencias con IA...', { id: 'suggestions' });
|
|
||||||
|
|
||||||
const suggestionsResult = await onboardingService.generateInventorySuggestions(tenantId, file, productList);
|
|
||||||
|
|
||||||
toast.success(`¡${suggestionsResult.total_products} productos clasificados! ${suggestionsResult.high_confidence_count} con alta confianza`, {
|
|
||||||
id: 'suggestions'
|
|
||||||
});
|
|
||||||
|
|
||||||
setState(prev => ({
|
|
||||||
...prev,
|
|
||||||
suggestionsResult,
|
|
||||||
reviewedSuggestions: suggestionsResult.suggestions.map(s => ({
|
|
||||||
...s,
|
|
||||||
user_approved: s.confidence_score >= 0.7
|
|
||||||
})),
|
|
||||||
phase: 'review'
|
|
||||||
}));
|
|
||||||
|
|
||||||
} catch (error: any) {
|
|
||||||
toast.error('Error al generar sugerencias', { id: 'suggestions' });
|
|
||||||
setState(prev => ({
|
|
||||||
...prev,
|
|
||||||
error: error.message || 'Error en sugerencias de IA',
|
|
||||||
phase: 'validation'
|
|
||||||
}));
|
|
||||||
} finally {
|
|
||||||
setIsProcessing(false);
|
|
||||||
}
|
|
||||||
}, [tenantId]);
|
|
||||||
|
|
||||||
const handleSuggestionUpdate = useCallback((suggestionId: string, updates: Partial<InventorySuggestion>) => {
|
|
||||||
setState(prev => ({
|
|
||||||
...prev,
|
|
||||||
reviewedSuggestions: prev.reviewedSuggestions?.map(s =>
|
|
||||||
s.suggestion_id === suggestionId ? { ...s, ...updates } : s
|
|
||||||
)
|
|
||||||
}));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleCreateInventory = useCallback(async () => {
|
|
||||||
if (!state.reviewedSuggestions) return;
|
|
||||||
|
|
||||||
setState(prev => ({ ...prev, phase: 'creation' }));
|
|
||||||
setIsProcessing(true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const approvedSuggestions = state.reviewedSuggestions.filter(s => s.user_approved);
|
|
||||||
|
|
||||||
if (approvedSuggestions.length === 0) {
|
|
||||||
toast.error('Debes aprobar al menos un producto para continuar');
|
|
||||||
setState(prev => ({ ...prev, phase: 'review' }));
|
|
||||||
setIsProcessing(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
toast.loading(`Creando ${approvedSuggestions.length} productos en tu inventario...`, { id: 'creation' });
|
|
||||||
|
|
||||||
const creationResult = await onboardingService.createInventoryFromSuggestions(
|
|
||||||
tenantId,
|
|
||||||
approvedSuggestions
|
|
||||||
);
|
|
||||||
|
|
||||||
toast.success(`¡${creationResult.created_items.length} productos creados exitosamente!`, {
|
|
||||||
id: 'creation'
|
|
||||||
});
|
|
||||||
|
|
||||||
setState(prev => ({ ...prev, creationResult, phase: 'import' }));
|
|
||||||
|
|
||||||
// Auto-proceed to final import
|
|
||||||
setTimeout(() => handleFinalImport(creationResult), 1500);
|
|
||||||
|
|
||||||
} catch (error: any) {
|
|
||||||
toast.error('Error al crear productos en inventario', { id: 'creation' });
|
|
||||||
setState(prev => ({
|
|
||||||
...prev,
|
|
||||||
error: error.message || 'Error al crear inventario',
|
|
||||||
phase: 'review'
|
|
||||||
}));
|
|
||||||
} finally {
|
|
||||||
setIsProcessing(false);
|
|
||||||
}
|
|
||||||
}, [state.reviewedSuggestions, tenantId]);
|
|
||||||
|
|
||||||
const handleFinalImport = useCallback(async (creationResult?: InventoryCreationResult) => {
|
|
||||||
if (!state.file || !state.reviewedSuggestions) return;
|
|
||||||
|
|
||||||
const currentCreationResult = creationResult || state.creationResult;
|
|
||||||
if (!currentCreationResult) return;
|
|
||||||
|
|
||||||
setIsProcessing(true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Create mapping from product names to inventory IDs
|
|
||||||
const inventoryMapping: Record<string, string> = {};
|
|
||||||
|
|
||||||
currentCreationResult.created_items.forEach(item => {
|
|
||||||
// Find the original suggestion that created this item
|
|
||||||
const suggestion = state.reviewedSuggestions!.find(s =>
|
|
||||||
s.suggested_name === item.name || s.original_name === item.original_name
|
|
||||||
);
|
|
||||||
|
|
||||||
if (suggestion) {
|
|
||||||
inventoryMapping[suggestion.original_name] = item.id;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
toast.loading('Importando datos históricos con inventario...', { id: 'import' });
|
|
||||||
|
|
||||||
const importResult = await onboardingService.importSalesWithInventory(
|
|
||||||
tenantId,
|
|
||||||
state.file,
|
|
||||||
inventoryMapping
|
|
||||||
);
|
|
||||||
|
|
||||||
toast.success(
|
|
||||||
`¡Importación completada! ${importResult.successful_imports} registros importados`,
|
|
||||||
{ id: 'import' }
|
|
||||||
);
|
|
||||||
|
|
||||||
setState(prev => ({ ...prev, importResult, phase: 'complete' }));
|
|
||||||
|
|
||||||
// Complete the process
|
|
||||||
setTimeout(() => onComplete(importResult), 2000);
|
|
||||||
|
|
||||||
} catch (error: any) {
|
|
||||||
toast.error('Error en importación final', { id: 'import' });
|
|
||||||
setState(prev => ({
|
|
||||||
...prev,
|
|
||||||
error: error.message || 'Error en importación final',
|
|
||||||
phase: 'creation'
|
|
||||||
}));
|
|
||||||
} finally {
|
|
||||||
setIsProcessing(false);
|
|
||||||
}
|
|
||||||
}, [state.file, state.reviewedSuggestions, state.creationResult, tenantId, onComplete]);
|
|
||||||
|
|
||||||
const renderBusinessModelInsight = (analysis: BusinessModelAnalysis) => {
|
|
||||||
const modelConfig = {
|
|
||||||
individual_bakery: {
|
|
||||||
icon: Factory,
|
|
||||||
title: 'Panadería Individual',
|
|
||||||
description: 'Producción completa desde ingredientes básicos (harina, levadura, etc.)',
|
|
||||||
color: 'blue',
|
|
||||||
bgColor: 'bg-blue-50',
|
|
||||||
borderColor: 'border-blue-200',
|
|
||||||
textColor: 'text-blue-900',
|
|
||||||
businessType: 'bakery'
|
|
||||||
},
|
|
||||||
central_baker_satellite: {
|
|
||||||
icon: Truck,
|
|
||||||
title: 'Punto de Venta - Obrador Central',
|
|
||||||
description: 'Recibe productos semi-elaborados y los finaliza (horneado, decoración)',
|
|
||||||
color: 'amber',
|
|
||||||
bgColor: 'bg-amber-50',
|
|
||||||
borderColor: 'border-amber-200',
|
|
||||||
textColor: 'text-amber-900',
|
|
||||||
businessType: 'bakery'
|
|
||||||
},
|
|
||||||
retail_bakery: {
|
|
||||||
icon: Store,
|
|
||||||
title: 'Panadería de Distribución',
|
|
||||||
description: 'Vende productos terminados de proveedores externos',
|
|
||||||
color: 'green',
|
|
||||||
bgColor: 'bg-green-50',
|
|
||||||
borderColor: 'border-green-200',
|
|
||||||
textColor: 'text-green-900',
|
|
||||||
businessType: 'bakery'
|
|
||||||
},
|
|
||||||
hybrid_bakery: {
|
|
||||||
icon: Settings2,
|
|
||||||
title: 'Modelo Mixto',
|
|
||||||
description: 'Combina producción propia con productos de proveedores',
|
|
||||||
color: 'purple',
|
|
||||||
bgColor: 'bg-purple-50',
|
|
||||||
borderColor: 'border-purple-200',
|
|
||||||
textColor: 'text-purple-900',
|
|
||||||
businessType: 'bakery'
|
|
||||||
},
|
|
||||||
coffee_shop_individual: {
|
|
||||||
icon: Coffee,
|
|
||||||
title: 'Cafetería Individual',
|
|
||||||
description: 'Servicio de bebidas y comida ligera con preparación in-situ',
|
|
||||||
color: 'amber',
|
|
||||||
bgColor: 'bg-amber-50',
|
|
||||||
borderColor: 'border-amber-200',
|
|
||||||
textColor: 'text-amber-900',
|
|
||||||
businessType: 'coffee_shop'
|
|
||||||
},
|
|
||||||
coffee_shop_chain: {
|
|
||||||
icon: Building2,
|
|
||||||
title: 'Cafetería en Cadena',
|
|
||||||
description: 'Múltiples ubicaciones con productos estandarizados',
|
|
||||||
color: 'indigo',
|
|
||||||
bgColor: 'bg-indigo-50',
|
|
||||||
borderColor: 'border-indigo-200',
|
|
||||||
textColor: 'text-indigo-900',
|
|
||||||
businessType: 'coffee_shop'
|
|
||||||
},
|
|
||||||
// Legacy fallbacks
|
|
||||||
production: {
|
|
||||||
icon: Factory,
|
|
||||||
title: 'Panadería de Producción',
|
|
||||||
description: 'Produces items from raw ingredients',
|
|
||||||
color: 'blue',
|
|
||||||
bgColor: 'bg-blue-50',
|
|
||||||
borderColor: 'border-blue-200',
|
|
||||||
textColor: 'text-blue-900',
|
|
||||||
businessType: 'bakery'
|
|
||||||
},
|
|
||||||
retail: {
|
|
||||||
icon: Store,
|
|
||||||
title: 'Panadería de Distribución',
|
|
||||||
description: 'Sells finished products from suppliers',
|
|
||||||
color: 'green',
|
|
||||||
bgColor: 'bg-green-50',
|
|
||||||
borderColor: 'border-green-200',
|
|
||||||
textColor: 'text-green-900',
|
|
||||||
businessType: 'bakery'
|
|
||||||
},
|
|
||||||
hybrid: {
|
|
||||||
icon: Settings2,
|
|
||||||
title: 'Modelo Híbrido',
|
|
||||||
description: 'Both produces and distributes products',
|
|
||||||
color: 'purple',
|
|
||||||
bgColor: 'bg-purple-50',
|
|
||||||
borderColor: 'border-purple-200',
|
|
||||||
textColor: 'text-purple-900',
|
|
||||||
businessType: 'bakery'
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Provide fallback if analysis.model is not in modelConfig
|
|
||||||
const config = modelConfig[analysis.model as keyof typeof modelConfig] || modelConfig.hybrid_bakery;
|
|
||||||
const IconComponent = config.icon;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={`${config.bgColor} ${config.borderColor} border rounded-xl p-6 mb-6`}>
|
|
||||||
<div className="flex items-start space-x-4">
|
|
||||||
<div className={`w-12 h-12 ${config.bgColor} rounded-lg flex items-center justify-center`}>
|
|
||||||
<IconComponent className={`w-6 h-6 text-${config.color}-600`} />
|
|
||||||
</div>
|
|
||||||
<div className="flex-1">
|
|
||||||
<div className="flex items-center justify-between mb-2">
|
|
||||||
<h3 className={`font-semibold ${config.textColor}`}>{config.title}</h3>
|
|
||||||
<span className={`px-3 py-1 bg-white rounded-full text-sm font-medium text-${config.color}-600`}>
|
|
||||||
{Math.round(analysis.confidence * 100)}% confianza
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<p className={`text-sm ${config.textColor} mb-3`}>{config.description}</p>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4 mb-4">
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<Wheat className="w-4 h-4 text-amber-500" />
|
|
||||||
<span className="text-sm text-gray-700">
|
|
||||||
{analysis.ingredient_count} ingredientes
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<Package className="w-4 h-4 text-green-500" />
|
|
||||||
<span className="text-sm text-gray-700">
|
|
||||||
{analysis.finished_product_count} productos finales
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Enhanced business intelligence insights if available */}
|
|
||||||
{config.businessType === 'coffee_shop' && (
|
|
||||||
<div className="mb-4 p-3 bg-amber-100 border border-amber-200 rounded-lg">
|
|
||||||
<div className="flex items-center space-x-2 mb-2">
|
|
||||||
<Coffee className="w-4 h-4 text-amber-600" />
|
|
||||||
<span className="text-sm font-medium text-amber-800">
|
|
||||||
Negocio de Cafetería Detectado
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-amber-700">
|
|
||||||
Hemos detectado que tu negocio se enfoca principalmente en bebidas y comida ligera.
|
|
||||||
El sistema se optimizará para gestión de inventario de cafetería.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{analysis.recommendations.length > 0 && (
|
|
||||||
<div>
|
|
||||||
<h4 className={`text-sm font-medium ${config.textColor} mb-2`}>
|
|
||||||
Recomendaciones personalizadas:
|
|
||||||
</h4>
|
|
||||||
<ul className="space-y-1">
|
|
||||||
{analysis.recommendations.slice(0, 2).map((rec, idx) => (
|
|
||||||
<li key={idx} className={`text-sm ${config.textColor} flex items-center space-x-2`}>
|
|
||||||
<Lightbulb className="w-3 h-3" />
|
|
||||||
<span>{rec}</span>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderSuggestionCard = (suggestion: InventorySuggestion) => {
|
|
||||||
const isHighConfidence = suggestion.confidence_score >= 0.7;
|
|
||||||
const isMediumConfidence = suggestion.confidence_score >= 0.4;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={suggestion.suggestion_id}
|
|
||||||
className={`border rounded-lg p-4 transition-all ${
|
|
||||||
suggestion.user_approved
|
|
||||||
? 'border-green-300 bg-green-50'
|
|
||||||
: 'border-gray-200 bg-white hover:border-gray-300'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className="flex items-start justify-between mb-3">
|
|
||||||
<div className="flex-1">
|
|
||||||
<div className="flex items-center space-x-3">
|
|
||||||
<button
|
|
||||||
onClick={() => handleSuggestionUpdate(suggestion.suggestion_id, {
|
|
||||||
user_approved: !suggestion.user_approved
|
|
||||||
})}
|
|
||||||
className={`w-5 h-5 rounded border-2 flex items-center justify-center transition-colors ${
|
|
||||||
suggestion.user_approved
|
|
||||||
? 'bg-green-500 border-green-500 text-white'
|
|
||||||
: 'border-gray-300 hover:border-green-300'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{suggestion.user_approved && <Check className="w-3 h-3" />}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<h4 className="font-medium text-gray-900">{suggestion.suggested_name}</h4>
|
|
||||||
{suggestion.original_name !== suggestion.suggested_name && (
|
|
||||||
<p className="text-sm text-gray-500">"{suggestion.original_name}"</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="text-right">
|
|
||||||
<div className={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium ${
|
|
||||||
isHighConfidence ? 'bg-green-100 text-green-800' :
|
|
||||||
isMediumConfidence ? 'bg-yellow-100 text-yellow-800' :
|
|
||||||
'bg-red-100 text-red-800'
|
|
||||||
}`}>
|
|
||||||
{Math.round(suggestion.confidence_score * 100)}% confianza
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
|
||||||
<div>
|
|
||||||
<span className="text-gray-500">Tipo:</span>
|
|
||||||
<span className="ml-2 font-medium">
|
|
||||||
{suggestion.product_type === 'ingredient' ? 'Ingrediente' : 'Producto Final'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span className="text-gray-500">Categoría:</span>
|
|
||||||
<span className="ml-2 font-medium">{suggestion.category}</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span className="text-gray-500">Unidad:</span>
|
|
||||||
<span className="ml-2 font-medium">{suggestion.unit_of_measure}</span>
|
|
||||||
</div>
|
|
||||||
{suggestion.estimated_shelf_life_days && (
|
|
||||||
<div>
|
|
||||||
<span className="text-gray-500">Duración:</span>
|
|
||||||
<span className="ml-2 font-medium">{suggestion.estimated_shelf_life_days} días</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{(suggestion.requires_refrigeration || suggestion.requires_freezing || suggestion.is_seasonal) && (
|
|
||||||
<div className="mt-3 flex flex-wrap gap-2">
|
|
||||||
{suggestion.requires_refrigeration && (
|
|
||||||
<span className="px-2 py-1 bg-blue-100 text-blue-800 text-xs rounded-full">
|
|
||||||
❄️ Refrigeración
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{suggestion.requires_freezing && (
|
|
||||||
<span className="px-2 py-1 bg-blue-100 text-blue-800 text-xs rounded-full">
|
|
||||||
🧊 Congelación
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{suggestion.is_seasonal && (
|
|
||||||
<span className="px-2 py-1 bg-orange-100 text-orange-800 text-xs rounded-full">
|
|
||||||
🍂 Estacional
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!isHighConfidence && suggestion.notes && (
|
|
||||||
<div className="mt-3 p-2 bg-amber-50 border border-amber-200 rounded text-xs text-amber-800">
|
|
||||||
💡 {suggestion.notes}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Main render logic based on current phase
|
|
||||||
switch (state.phase) {
|
|
||||||
case 'upload':
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div className="text-center mb-8">
|
|
||||||
<div className="w-16 h-16 bg-gradient-to-r from-blue-500 to-purple-500 rounded-full flex items-center justify-center mx-auto mb-4">
|
|
||||||
<Brain className="w-8 h-8 text-white" />
|
|
||||||
</div>
|
|
||||||
<h2 className="text-2xl font-bold text-gray-900 mb-2">
|
|
||||||
Importación Inteligente de Datos
|
|
||||||
</h2>
|
|
||||||
<p className="text-gray-600">
|
|
||||||
Nuestra IA analizará tus datos históricos y creará automáticamente tu inventario
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-gradient-to-r from-blue-50 to-purple-50 border border-blue-200 rounded-xl p-6">
|
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">
|
|
||||||
🚀 ¿Cómo funciona la magia?
|
|
||||||
</h3>
|
|
||||||
<div className="grid md:grid-cols-3 gap-4">
|
|
||||||
<div className="text-center">
|
|
||||||
<div className="w-12 h-12 bg-blue-500 rounded-full flex items-center justify-center mx-auto mb-3">
|
|
||||||
<Upload className="w-6 h-6 text-white" />
|
|
||||||
</div>
|
|
||||||
<div className="font-medium text-gray-900">1. Subes tu archivo</div>
|
|
||||||
<div className="text-sm text-gray-600 mt-1">CSV, Excel o JSON</div>
|
|
||||||
</div>
|
|
||||||
<div className="text-center">
|
|
||||||
<div className="w-12 h-12 bg-purple-500 rounded-full flex items-center justify-center mx-auto mb-3">
|
|
||||||
<Brain className="w-6 h-6 text-white" />
|
|
||||||
</div>
|
|
||||||
<div className="font-medium text-gray-900">2. IA analiza productos</div>
|
|
||||||
<div className="text-sm text-gray-600 mt-1">Clasificación inteligente</div>
|
|
||||||
</div>
|
|
||||||
<div className="text-center">
|
|
||||||
<div className="w-12 h-12 bg-green-500 rounded-full flex items-center justify-center mx-auto mb-3">
|
|
||||||
<Package className="w-6 h-6 text-white" />
|
|
||||||
</div>
|
|
||||||
<div className="font-medium text-gray-900">3. Inventario listo</div>
|
|
||||||
<div className="text-sm text-gray-600 mt-1">Con categorías y detalles</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="border-2 border-dashed border-gray-300 rounded-xl p-8 text-center hover:border-blue-300 transition-colors">
|
|
||||||
<Upload className="mx-auto h-12 w-12 text-gray-400 mb-4" />
|
|
||||||
<label htmlFor="smart-upload" className="cursor-pointer">
|
|
||||||
<span className="text-lg font-medium text-gray-900 block mb-2">
|
|
||||||
Sube tu archivo de datos históricos
|
|
||||||
</span>
|
|
||||||
<span className="text-gray-600">
|
|
||||||
Arrastra tu archivo aquí o haz clic para seleccionar
|
|
||||||
</span>
|
|
||||||
<span className="block text-sm text-gray-400 mt-2">
|
|
||||||
Máximo 10MB - CSV, Excel (.xlsx, .xls), JSON
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="smart-upload"
|
|
||||||
type="file"
|
|
||||||
accept=".csv,.xlsx,.xls,.json"
|
|
||||||
onChange={(e) => {
|
|
||||||
const file = e.target.files?.[0];
|
|
||||||
if (file) {
|
|
||||||
if (file.size > 10 * 1024 * 1024) {
|
|
||||||
toast.error('El archivo es demasiado grande. Máximo 10MB.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
handleFileUpload(file);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="hidden"
|
|
||||||
disabled={isProcessing}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{state.error && (
|
|
||||||
<div className="p-4 bg-red-50 border border-red-200 rounded-lg">
|
|
||||||
<div className="flex">
|
|
||||||
<XCircle className="h-5 w-5 text-red-400" />
|
|
||||||
<div className="ml-3">
|
|
||||||
<h3 className="text-sm font-medium text-red-800">Error</h3>
|
|
||||||
<p className="text-sm text-red-700 mt-1">{state.error}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
case 'validation':
|
|
||||||
return (
|
|
||||||
<div className="text-center py-12">
|
|
||||||
<div className="w-20 h-20 bg-gradient-to-r from-blue-500 to-green-500 rounded-full flex items-center justify-center mx-auto mb-6 animate-pulse">
|
|
||||||
<CheckCircle2 className="w-10 h-10 text-white" />
|
|
||||||
</div>
|
|
||||||
<h2 className="text-xl font-semibold text-gray-900 mb-3">
|
|
||||||
📋 Validando archivo...
|
|
||||||
</h2>
|
|
||||||
<p className="text-gray-600 mb-6">
|
|
||||||
Verificando formato y extrayendo productos únicos
|
|
||||||
</p>
|
|
||||||
<div className="bg-white rounded-lg shadow-sm p-4 max-w-md mx-auto">
|
|
||||||
<div className="flex items-center justify-between text-sm text-gray-600">
|
|
||||||
<span>Archivo:</span>
|
|
||||||
<span className="font-medium">{state.file?.name}</span>
|
|
||||||
</div>
|
|
||||||
<div className="mt-2 bg-gray-200 rounded-full h-2">
|
|
||||||
<div className="bg-gradient-to-r from-blue-500 to-green-500 h-2 rounded-full w-1/3 animate-pulse"></div>
|
|
||||||
</div>
|
|
||||||
<div className="mt-2 text-xs text-gray-500">Paso 1 de 4: Validación</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
case 'suggestions':
|
|
||||||
return (
|
|
||||||
<div className="text-center py-12">
|
|
||||||
<div className="w-20 h-20 bg-gradient-to-r from-purple-500 to-pink-500 rounded-full flex items-center justify-center mx-auto mb-6 animate-pulse">
|
|
||||||
<Brain className="w-10 h-10 text-white" />
|
|
||||||
</div>
|
|
||||||
<h2 className="text-xl font-semibold text-gray-900 mb-3">
|
|
||||||
🧠 Generando sugerencias con IA...
|
|
||||||
</h2>
|
|
||||||
<p className="text-gray-600 mb-6">
|
|
||||||
Clasificando productos y analizando tu modelo de negocio
|
|
||||||
</p>
|
|
||||||
<div className="bg-white rounded-lg shadow-sm p-4 max-w-md mx-auto">
|
|
||||||
{state.validationResult && (
|
|
||||||
<div className="mb-4">
|
|
||||||
<div className="flex items-center justify-center space-x-2 text-green-600 mb-2">
|
|
||||||
<CheckCircle2 className="w-4 h-4" />
|
|
||||||
<span className="text-sm font-medium">
|
|
||||||
{state.validationResult.unique_products} productos únicos encontrados
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="bg-gray-200 rounded-full h-2">
|
|
||||||
<div className="bg-gradient-to-r from-purple-500 to-pink-500 h-2 rounded-full w-2/3 animate-pulse"></div>
|
|
||||||
</div>
|
|
||||||
<div className="mt-2 text-xs text-gray-500">Paso 2 de 4: Clasificación IA</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
case 'review':
|
|
||||||
if (!state.suggestionsResult) return null;
|
|
||||||
|
|
||||||
const { suggestionsResult, reviewedSuggestions } = state;
|
|
||||||
const approvedCount = reviewedSuggestions?.filter(s => s.user_approved).length || 0;
|
|
||||||
const highConfidenceCount = reviewedSuggestions?.filter(s => s.confidence_score >= 0.7).length || 0;
|
|
||||||
const visibleSuggestions = showAllSuggestions
|
|
||||||
? reviewedSuggestions
|
|
||||||
: reviewedSuggestions?.slice(0, 6);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div className="text-center">
|
|
||||||
<div className="w-16 h-16 bg-gradient-to-r from-green-400 to-blue-500 rounded-full flex items-center justify-center mx-auto mb-4">
|
|
||||||
<CheckCircle2 className="w-8 h-8 text-white" />
|
|
||||||
</div>
|
|
||||||
<h2 className="text-2xl font-bold text-gray-900 mb-2">
|
|
||||||
¡Análisis Completado! 🎉
|
|
||||||
</h2>
|
|
||||||
<p className="text-gray-600">
|
|
||||||
Hemos encontrado <strong>{suggestionsResult.total_products} productos</strong> y
|
|
||||||
sugerimos <strong>{approvedCount} para tu inventario</strong>
|
|
||||||
</p>
|
|
||||||
<div className="mt-2 text-sm text-gray-500">
|
|
||||||
⚡ Procesado en {suggestionsResult.processing_time_seconds.toFixed(1)}s
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{renderBusinessModelInsight(suggestionsResult.business_model_analysis)}
|
|
||||||
|
|
||||||
<div className="bg-white border rounded-xl p-6">
|
|
||||||
<div className="flex items-center justify-between mb-4">
|
|
||||||
<div>
|
|
||||||
<h3 className="text-lg font-semibold text-gray-900">
|
|
||||||
Productos Sugeridos para tu Inventario
|
|
||||||
</h3>
|
|
||||||
<p className="text-sm text-gray-600">
|
|
||||||
{highConfidenceCount} con alta confianza • {approvedCount} pre-aprobados
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
const allApproved = approvedCount === reviewedSuggestions?.length;
|
|
||||||
setState(prev => ({
|
|
||||||
...prev,
|
|
||||||
reviewedSuggestions: prev.reviewedSuggestions?.map(s => ({
|
|
||||||
...s,
|
|
||||||
user_approved: !allApproved
|
|
||||||
}))
|
|
||||||
}));
|
|
||||||
}}
|
|
||||||
className="px-3 py-1 text-sm border border-gray-300 rounded-lg hover:bg-gray-50"
|
|
||||||
>
|
|
||||||
{approvedCount === reviewedSuggestions?.length ? 'Desaprobar todos' : 'Aprobar todos'}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{(reviewedSuggestions?.length || 0) > 6 && (
|
|
||||||
<button
|
|
||||||
onClick={() => setShowAllSuggestions(!showAllSuggestions)}
|
|
||||||
className="flex items-center px-3 py-1 text-sm text-blue-600 hover:bg-blue-50 rounded-lg"
|
|
||||||
>
|
|
||||||
{showAllSuggestions ? (
|
|
||||||
<>
|
|
||||||
<EyeOff className="w-4 h-4 mr-1" />
|
|
||||||
Ver menos
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Eye className="w-4 h-4 mr-1" />
|
|
||||||
Ver todos ({reviewedSuggestions?.length})
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-4 mb-6">
|
|
||||||
{visibleSuggestions?.map(renderSuggestionCard)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{state.validationResult?.validation_warnings && state.validationResult.validation_warnings.length > 0 && (
|
|
||||||
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4 mb-4">
|
|
||||||
<div className="flex">
|
|
||||||
<AlertTriangle className="h-5 w-5 text-amber-400" />
|
|
||||||
<div className="ml-3">
|
|
||||||
<h4 className="text-sm font-medium text-amber-800">Advertencias de Validación</h4>
|
|
||||||
<ul className="mt-2 text-sm text-amber-700 space-y-1">
|
|
||||||
{state.validationResult.validation_warnings.map((warning, idx) => (
|
|
||||||
<li key={idx}>• {warning.message || warning}</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex justify-between items-center">
|
|
||||||
{onBack && (
|
|
||||||
<button
|
|
||||||
onClick={onBack}
|
|
||||||
className="px-4 py-2 text-gray-600 hover:text-gray-800"
|
|
||||||
>
|
|
||||||
← Volver
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={handleCreateInventory}
|
|
||||||
disabled={approvedCount === 0 || isProcessing}
|
|
||||||
className="flex items-center px-6 py-3 bg-gradient-to-r from-green-500 to-blue-500 text-white rounded-xl hover:from-green-600 hover:to-blue-600 disabled:opacity-50 disabled:cursor-not-allowed transition-all"
|
|
||||||
>
|
|
||||||
{isProcessing ? (
|
|
||||||
<>
|
|
||||||
<Loader className="w-5 h-5 mr-2 animate-spin" />
|
|
||||||
Creando inventario...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
Crear inventario ({approvedCount} productos)
|
|
||||||
<ArrowRight className="w-5 h-5 ml-2" />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
case 'creation':
|
|
||||||
case 'import':
|
|
||||||
const isCreating = state.phase === 'creation';
|
|
||||||
const stepNumber = isCreating ? 3 : 4;
|
|
||||||
const stepProgress = isCreating ? 75 : 90;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="text-center py-12">
|
|
||||||
<div className="w-20 h-20 bg-gradient-to-r from-green-400 to-blue-500 rounded-full flex items-center justify-center mx-auto mb-6 animate-pulse">
|
|
||||||
{isCreating ? (
|
|
||||||
<Package className="w-10 h-10 text-white" />
|
|
||||||
) : (
|
|
||||||
<Upload className="w-10 h-10 text-white" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<h2 className="text-xl font-semibold text-gray-900 mb-3">
|
|
||||||
{isCreating ? '📦 Creando productos en tu inventario...' : '📊 Importando datos históricos...'}
|
|
||||||
</h2>
|
|
||||||
<p className="text-gray-600 mb-6">
|
|
||||||
{isCreating
|
|
||||||
? 'Configurando cada producto con sus detalles específicos'
|
|
||||||
: 'Vinculando tus ventas históricas con el nuevo inventario'
|
|
||||||
}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="bg-white rounded-lg shadow-sm p-6 max-w-md mx-auto">
|
|
||||||
{state.creationResult && (
|
|
||||||
<div className="mb-4">
|
|
||||||
<div className="flex items-center justify-center space-x-2 text-green-600 mb-2">
|
|
||||||
<CheckCircle2 className="w-5 h-5" />
|
|
||||||
<span className="font-medium">
|
|
||||||
{state.creationResult.created_items.length} productos creados
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="bg-gray-200 rounded-full h-3">
|
|
||||||
<div
|
|
||||||
className="bg-gradient-to-r from-green-400 to-blue-500 h-3 rounded-full animate-pulse transition-all duration-500"
|
|
||||||
style={{ width: `${stepProgress}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between items-center mt-2">
|
|
||||||
<p className="text-sm text-gray-500">
|
|
||||||
{isCreating ? 'Creando inventario...' : 'Procesando importación final...'}
|
|
||||||
</p>
|
|
||||||
<span className="text-xs text-gray-400">Paso {stepNumber} de 4</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
case 'complete':
|
|
||||||
if (!state.importResult || !state.creationResult) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="text-center py-12">
|
|
||||||
<div className="w-24 h-24 bg-gradient-to-r from-green-400 to-green-600 rounded-full flex items-center justify-center mx-auto mb-6 animate-bounce">
|
|
||||||
<CheckCircle2 className="w-12 h-12 text-white" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h2 className="text-3xl font-bold text-gray-900 mb-4">
|
|
||||||
¡Importación Completada! 🎉
|
|
||||||
</h2>
|
|
||||||
<p className="text-xl text-gray-600 mb-8">
|
|
||||||
Tu inventario inteligente está listo
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="bg-gradient-to-r from-green-50 to-blue-50 rounded-3xl p-8 max-w-2xl mx-auto">
|
|
||||||
<div className="grid md:grid-cols-2 gap-6">
|
|
||||||
<div className="text-center">
|
|
||||||
<div className="w-16 h-16 bg-green-500 rounded-full flex items-center justify-center mx-auto mb-4">
|
|
||||||
<Package className="w-8 h-8 text-white" />
|
|
||||||
</div>
|
|
||||||
<div className="text-2xl font-bold text-green-600">
|
|
||||||
{state.creationResult.created_items.length}
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-gray-600">Productos en inventario</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="text-center">
|
|
||||||
<div className="w-16 h-16 bg-blue-500 rounded-full flex items-center justify-center mx-auto mb-4">
|
|
||||||
<Upload className="w-8 h-8 text-white" />
|
|
||||||
</div>
|
|
||||||
<div className="text-2xl font-bold text-blue-600">
|
|
||||||
{state.importResult.successful_imports}
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-gray-600">Registros históricos</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-6 text-center">
|
|
||||||
<p className="text-sm text-gray-600">
|
|
||||||
✨ Tu IA está lista para predecir la demanda con precisión
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
default:
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export default SmartHistoricalDataImport;
|
|
||||||
@@ -1,356 +0,0 @@
|
|||||||
import React, { useState } from 'react';
|
|
||||||
import {
|
|
||||||
Edit,
|
|
||||||
RefreshCw,
|
|
||||||
Globe,
|
|
||||||
Activity,
|
|
||||||
CheckCircle,
|
|
||||||
AlertTriangle,
|
|
||||||
Clock,
|
|
||||||
Settings,
|
|
||||||
MoreVertical,
|
|
||||||
Trash2,
|
|
||||||
Power,
|
|
||||||
Zap
|
|
||||||
} from 'lucide-react';
|
|
||||||
|
|
||||||
import Button from '../ui/Button';
|
|
||||||
import Card from '../ui/Card';
|
|
||||||
import LoadingSpinner from '../ui/LoadingSpinner';
|
|
||||||
|
|
||||||
interface POSConfiguration {
|
|
||||||
id: string;
|
|
||||||
pos_system: string;
|
|
||||||
provider_name: string;
|
|
||||||
is_active: boolean;
|
|
||||||
is_connected: boolean;
|
|
||||||
environment: string;
|
|
||||||
location_id?: string;
|
|
||||||
merchant_id?: string;
|
|
||||||
sync_enabled: boolean;
|
|
||||||
sync_interval_minutes: string;
|
|
||||||
auto_sync_products: boolean;
|
|
||||||
auto_sync_transactions: boolean;
|
|
||||||
last_sync_at?: string;
|
|
||||||
last_successful_sync_at?: string;
|
|
||||||
last_sync_status?: string;
|
|
||||||
health_status: string;
|
|
||||||
created_at: string;
|
|
||||||
updated_at: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface POSConfigurationCardProps {
|
|
||||||
configuration: POSConfiguration;
|
|
||||||
onEdit: (config: POSConfiguration) => void;
|
|
||||||
onTriggerSync: (configId: string) => Promise<void>;
|
|
||||||
onTestConnection: (configId: string) => Promise<void>;
|
|
||||||
onToggleActive?: (configId: string, active: boolean) => Promise<void>;
|
|
||||||
onDelete?: (configId: string) => Promise<void>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const POSConfigurationCard: React.FC<POSConfigurationCardProps> = ({
|
|
||||||
configuration,
|
|
||||||
onEdit,
|
|
||||||
onTriggerSync,
|
|
||||||
onTestConnection,
|
|
||||||
onToggleActive,
|
|
||||||
onDelete
|
|
||||||
}) => {
|
|
||||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
|
||||||
const [isSyncing, setIsSyncing] = useState(false);
|
|
||||||
const [isTesting, setIsTesting] = useState(false);
|
|
||||||
const [isToggling, setIsToggling] = useState(false);
|
|
||||||
|
|
||||||
const getPOSSystemIcon = (system: string) => {
|
|
||||||
switch (system) {
|
|
||||||
case 'square': return '⬜';
|
|
||||||
case 'toast': return '🍞';
|
|
||||||
case 'lightspeed': return '⚡';
|
|
||||||
default: return '💳';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getStatusColor = (status: string) => {
|
|
||||||
switch (status) {
|
|
||||||
case 'healthy': return 'text-green-600';
|
|
||||||
case 'unhealthy': return 'text-red-600';
|
|
||||||
case 'warning': return 'text-yellow-600';
|
|
||||||
default: return 'text-gray-600';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getStatusIcon = (status: string) => {
|
|
||||||
switch (status) {
|
|
||||||
case 'healthy': return CheckCircle;
|
|
||||||
case 'unhealthy': return AlertTriangle;
|
|
||||||
case 'warning': return Clock;
|
|
||||||
default: return Activity;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getConnectionStatusColor = (isConnected: boolean) => {
|
|
||||||
return isConnected ? 'text-green-600' : 'text-red-600';
|
|
||||||
};
|
|
||||||
|
|
||||||
const getSyncStatusColor = (status?: string) => {
|
|
||||||
switch (status) {
|
|
||||||
case 'success': return 'text-green-600';
|
|
||||||
case 'failed': return 'text-red-600';
|
|
||||||
case 'partial': return 'text-yellow-600';
|
|
||||||
default: return 'text-gray-600';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatLastSync = (timestamp?: string) => {
|
|
||||||
if (!timestamp) return 'Never';
|
|
||||||
|
|
||||||
const date = new Date(timestamp);
|
|
||||||
const now = new Date();
|
|
||||||
const diffMs = now.getTime() - date.getTime();
|
|
||||||
const diffMins = Math.floor(diffMs / (1000 * 60));
|
|
||||||
|
|
||||||
if (diffMins < 1) return 'Just now';
|
|
||||||
if (diffMins < 60) return `${diffMins}m ago`;
|
|
||||||
if (diffMins < 1440) return `${Math.floor(diffMins / 60)}h ago`;
|
|
||||||
return `${Math.floor(diffMins / 1440)}d ago`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSync = async () => {
|
|
||||||
setIsSyncing(true);
|
|
||||||
try {
|
|
||||||
await onTriggerSync(configuration.id);
|
|
||||||
} finally {
|
|
||||||
setIsSyncing(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleTestConnection = async () => {
|
|
||||||
setIsTesting(true);
|
|
||||||
try {
|
|
||||||
await onTestConnection(configuration.id);
|
|
||||||
} finally {
|
|
||||||
setIsTesting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleToggleActive = async () => {
|
|
||||||
if (!onToggleActive) return;
|
|
||||||
|
|
||||||
setIsToggling(true);
|
|
||||||
try {
|
|
||||||
await onToggleActive(configuration.id, !configuration.is_active);
|
|
||||||
} finally {
|
|
||||||
setIsToggling(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const StatusIcon = getStatusIcon(configuration.health_status);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card className="p-6 hover:shadow-lg transition-shadow">
|
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-start justify-between mb-4">
|
|
||||||
<div className="flex items-center space-x-3">
|
|
||||||
<div className="text-2xl">
|
|
||||||
{getPOSSystemIcon(configuration.pos_system)}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h3 className="font-semibold text-gray-900">
|
|
||||||
{configuration.provider_name}
|
|
||||||
</h3>
|
|
||||||
<p className="text-sm text-gray-600 capitalize">
|
|
||||||
{configuration.pos_system} • {configuration.environment}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="relative">
|
|
||||||
<button
|
|
||||||
onClick={() => setIsMenuOpen(!isMenuOpen)}
|
|
||||||
className="p-1 text-gray-400 hover:text-gray-600 transition-colors"
|
|
||||||
>
|
|
||||||
<MoreVertical className="h-5 w-5" />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{isMenuOpen && (
|
|
||||||
<div className="absolute right-0 top-8 w-48 bg-white rounded-md shadow-lg border z-10">
|
|
||||||
<div className="py-1">
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
onEdit(configuration);
|
|
||||||
setIsMenuOpen(false);
|
|
||||||
}}
|
|
||||||
className="w-full px-4 py-2 text-left text-sm text-gray-700 hover:bg-gray-100 flex items-center space-x-2"
|
|
||||||
>
|
|
||||||
<Edit className="h-4 w-4" />
|
|
||||||
<span>Edit Configuration</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{onToggleActive && (
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
handleToggleActive();
|
|
||||||
setIsMenuOpen(false);
|
|
||||||
}}
|
|
||||||
disabled={isToggling}
|
|
||||||
className="w-full px-4 py-2 text-left text-sm text-gray-700 hover:bg-gray-100 flex items-center space-x-2"
|
|
||||||
>
|
|
||||||
<Power className="h-4 w-4" />
|
|
||||||
<span>{configuration.is_active ? 'Deactivate' : 'Activate'}</span>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{onDelete && (
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
onDelete(configuration.id);
|
|
||||||
setIsMenuOpen(false);
|
|
||||||
}}
|
|
||||||
className="w-full px-4 py-2 text-left text-sm text-red-600 hover:bg-red-50 flex items-center space-x-2"
|
|
||||||
>
|
|
||||||
<Trash2 className="h-4 w-4" />
|
|
||||||
<span>Delete</span>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Status Indicators */}
|
|
||||||
<div className="grid grid-cols-3 gap-4 mb-4">
|
|
||||||
<div className="text-center">
|
|
||||||
<div className={`flex items-center justify-center space-x-1 ${getStatusColor(configuration.health_status)}`}>
|
|
||||||
<StatusIcon className="h-4 w-4" />
|
|
||||||
<span className="text-sm font-medium capitalize">{configuration.health_status}</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-gray-500">Health</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="text-center">
|
|
||||||
<div className={`flex items-center justify-center space-x-1 ${getConnectionStatusColor(configuration.is_connected)}`}>
|
|
||||||
<Globe className="h-4 w-4" />
|
|
||||||
<span className="text-sm font-medium">
|
|
||||||
{configuration.is_connected ? 'Connected' : 'Disconnected'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-gray-500">Connection</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="text-center">
|
|
||||||
<div className={`flex items-center justify-center space-x-1 ${getSyncStatusColor(configuration.last_sync_status)}`}>
|
|
||||||
<RefreshCw className="h-4 w-4" />
|
|
||||||
<span className="text-sm font-medium capitalize">
|
|
||||||
{configuration.last_sync_status || 'Unknown'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-gray-500">Last Sync</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Configuration Details */}
|
|
||||||
<div className="space-y-2 mb-4">
|
|
||||||
{configuration.location_id && (
|
|
||||||
<div className="flex justify-between text-sm">
|
|
||||||
<span className="text-gray-600">Location ID:</span>
|
|
||||||
<span className="font-medium">{configuration.location_id}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex justify-between text-sm">
|
|
||||||
<span className="text-gray-600">Sync Interval:</span>
|
|
||||||
<span className="font-medium">{configuration.sync_interval_minutes}m</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex justify-between text-sm">
|
|
||||||
<span className="text-gray-600">Last Sync:</span>
|
|
||||||
<span className="font-medium">{formatLastSync(configuration.last_sync_at)}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex justify-between text-sm">
|
|
||||||
<span className="text-gray-600">Auto Sync:</span>
|
|
||||||
<div className="flex space-x-1">
|
|
||||||
{configuration.auto_sync_transactions && (
|
|
||||||
<span className="px-2 py-1 bg-blue-100 text-blue-800 text-xs rounded">Transactions</span>
|
|
||||||
)}
|
|
||||||
{configuration.auto_sync_products && (
|
|
||||||
<span className="px-2 py-1 bg-green-100 text-green-800 text-xs rounded">Products</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Status Badge */}
|
|
||||||
<div className="flex items-center justify-between mb-4">
|
|
||||||
<div className="flex space-x-2">
|
|
||||||
<span className={`px-2 py-1 text-xs rounded-full ${
|
|
||||||
configuration.is_active
|
|
||||||
? 'bg-green-100 text-green-800'
|
|
||||||
: 'bg-gray-100 text-gray-800'
|
|
||||||
}`}>
|
|
||||||
{configuration.is_active ? 'Active' : 'Inactive'}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
{configuration.sync_enabled && (
|
|
||||||
<span className="px-2 py-1 bg-blue-100 text-blue-800 text-xs rounded-full">
|
|
||||||
Sync Enabled
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Action Buttons */}
|
|
||||||
<div className="flex space-x-2">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={handleTestConnection}
|
|
||||||
disabled={isTesting}
|
|
||||||
className="flex-1 flex items-center justify-center space-x-1"
|
|
||||||
>
|
|
||||||
{isTesting ? (
|
|
||||||
<LoadingSpinner size="sm" />
|
|
||||||
) : (
|
|
||||||
<Globe className="h-4 w-4" />
|
|
||||||
)}
|
|
||||||
<span>Test</span>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={handleSync}
|
|
||||||
disabled={isSyncing || !configuration.is_connected}
|
|
||||||
className="flex-1 flex items-center justify-center space-x-1"
|
|
||||||
>
|
|
||||||
{isSyncing ? (
|
|
||||||
<LoadingSpinner size="sm" />
|
|
||||||
) : (
|
|
||||||
<RefreshCw className="h-4 w-4" />
|
|
||||||
)}
|
|
||||||
<span>Sync</span>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
onClick={() => onEdit(configuration)}
|
|
||||||
className="flex-1 flex items-center justify-center space-x-1"
|
|
||||||
>
|
|
||||||
<Settings className="h-4 w-4" />
|
|
||||||
<span>Configure</span>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Click overlay to close menu */}
|
|
||||||
{isMenuOpen && (
|
|
||||||
<div
|
|
||||||
className="fixed inset-0 z-0"
|
|
||||||
onClick={() => setIsMenuOpen(false)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default POSConfigurationCard;
|
|
||||||
@@ -1,595 +0,0 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
|
||||||
import {
|
|
||||||
X,
|
|
||||||
Zap,
|
|
||||||
Settings,
|
|
||||||
Globe,
|
|
||||||
Key,
|
|
||||||
Webhook,
|
|
||||||
RefreshCw,
|
|
||||||
AlertTriangle,
|
|
||||||
CheckCircle,
|
|
||||||
Clock,
|
|
||||||
Database
|
|
||||||
} from 'lucide-react';
|
|
||||||
|
|
||||||
import Button from '../ui/Button';
|
|
||||||
import LoadingSpinner from '../ui/LoadingSpinner';
|
|
||||||
|
|
||||||
interface POSConfiguration {
|
|
||||||
id?: string;
|
|
||||||
pos_system: string;
|
|
||||||
provider_name: string;
|
|
||||||
is_active: boolean;
|
|
||||||
is_connected: boolean;
|
|
||||||
environment: string;
|
|
||||||
location_id?: string;
|
|
||||||
merchant_id?: string;
|
|
||||||
sync_enabled: boolean;
|
|
||||||
sync_interval_minutes: string;
|
|
||||||
auto_sync_products: boolean;
|
|
||||||
auto_sync_transactions: boolean;
|
|
||||||
webhook_url?: string;
|
|
||||||
notes?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface POSConfigurationFormProps {
|
|
||||||
configuration?: POSConfiguration | null;
|
|
||||||
isOpen: boolean;
|
|
||||||
isCreating?: boolean;
|
|
||||||
onSubmit: (data: POSConfiguration) => Promise<void>;
|
|
||||||
onClose: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface FormData extends POSConfiguration {
|
|
||||||
// Credentials (these won't be in the existing config for security)
|
|
||||||
api_key?: string;
|
|
||||||
api_secret?: string;
|
|
||||||
access_token?: string;
|
|
||||||
application_id?: string;
|
|
||||||
webhook_secret?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const SUPPORTED_POS_SYSTEMS = [
|
|
||||||
{
|
|
||||||
id: 'square',
|
|
||||||
name: 'Square POS',
|
|
||||||
description: 'Square Point of Sale system',
|
|
||||||
logo: '⬜',
|
|
||||||
fields: ['application_id', 'access_token', 'webhook_secret']
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'toast',
|
|
||||||
name: 'Toast POS',
|
|
||||||
description: 'Toast restaurant POS system',
|
|
||||||
logo: '🍞',
|
|
||||||
fields: ['api_key', 'api_secret', 'webhook_secret']
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'lightspeed',
|
|
||||||
name: 'Lightspeed Restaurant',
|
|
||||||
description: 'Lightspeed restaurant management system',
|
|
||||||
logo: '⚡',
|
|
||||||
fields: ['api_key', 'api_secret', 'cluster_id']
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
const POSConfigurationForm: React.FC<POSConfigurationFormProps> = ({
|
|
||||||
configuration,
|
|
||||||
isOpen,
|
|
||||||
isCreating = false,
|
|
||||||
onSubmit,
|
|
||||||
onClose
|
|
||||||
}) => {
|
|
||||||
const [formData, setFormData] = useState<FormData>({
|
|
||||||
pos_system: '',
|
|
||||||
provider_name: '',
|
|
||||||
is_active: true,
|
|
||||||
is_connected: false,
|
|
||||||
environment: 'sandbox',
|
|
||||||
sync_enabled: true,
|
|
||||||
sync_interval_minutes: '5',
|
|
||||||
auto_sync_products: true,
|
|
||||||
auto_sync_transactions: true,
|
|
||||||
...configuration
|
|
||||||
});
|
|
||||||
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
|
||||||
const [testingConnection, setTestingConnection] = useState(false);
|
|
||||||
const [connectionStatus, setConnectionStatus] = useState<'idle' | 'success' | 'error'>('idle');
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (configuration) {
|
|
||||||
setFormData({
|
|
||||||
...formData,
|
|
||||||
...configuration
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [configuration]);
|
|
||||||
|
|
||||||
const selectedPOSSystem = SUPPORTED_POS_SYSTEMS.find(sys => sys.id === formData.pos_system);
|
|
||||||
|
|
||||||
const validateForm = (): boolean => {
|
|
||||||
const newErrors: Record<string, string> = {};
|
|
||||||
|
|
||||||
if (!formData.pos_system) {
|
|
||||||
newErrors.pos_system = 'POS system is required';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!formData.provider_name.trim()) {
|
|
||||||
newErrors.provider_name = 'Provider name is required';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (selectedPOSSystem?.fields.includes('api_key') && !formData.api_key?.trim()) {
|
|
||||||
newErrors.api_key = 'API Key is required';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (selectedPOSSystem?.fields.includes('access_token') && !formData.access_token?.trim()) {
|
|
||||||
newErrors.access_token = 'Access Token is required';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (selectedPOSSystem?.fields.includes('application_id') && !formData.application_id?.trim()) {
|
|
||||||
newErrors.application_id = 'Application ID is required';
|
|
||||||
}
|
|
||||||
|
|
||||||
const syncInterval = parseInt(formData.sync_interval_minutes);
|
|
||||||
if (isNaN(syncInterval) || syncInterval < 1 || syncInterval > 1440) {
|
|
||||||
newErrors.sync_interval_minutes = 'Sync interval must be between 1 and 1440 minutes';
|
|
||||||
}
|
|
||||||
|
|
||||||
setErrors(newErrors);
|
|
||||||
return Object.keys(newErrors).length === 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
if (!validateForm()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsLoading(true);
|
|
||||||
try {
|
|
||||||
await onSubmit(formData);
|
|
||||||
onClose();
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error submitting form:', error);
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleTestConnection = async () => {
|
|
||||||
if (!validateForm()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setTestingConnection(true);
|
|
||||||
setConnectionStatus('idle');
|
|
||||||
|
|
||||||
try {
|
|
||||||
// TODO: Implement connection test API call
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 2000)); // Simulate API call
|
|
||||||
setConnectionStatus('success');
|
|
||||||
} catch (error) {
|
|
||||||
setConnectionStatus('error');
|
|
||||||
} finally {
|
|
||||||
setTestingConnection(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleInputChange = (field: keyof FormData, value: any) => {
|
|
||||||
setFormData(prev => ({
|
|
||||||
...prev,
|
|
||||||
[field]: value
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Clear error when user starts typing
|
|
||||||
if (errors[field]) {
|
|
||||||
setErrors(prev => ({
|
|
||||||
...prev,
|
|
||||||
[field]: ''
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlePOSSystemChange = (posSystem: string) => {
|
|
||||||
const system = SUPPORTED_POS_SYSTEMS.find(sys => sys.id === posSystem);
|
|
||||||
setFormData(prev => ({
|
|
||||||
...prev,
|
|
||||||
pos_system: posSystem,
|
|
||||||
provider_name: system?.name || '',
|
|
||||||
// Clear credentials when changing systems
|
|
||||||
api_key: '',
|
|
||||||
api_secret: '',
|
|
||||||
access_token: '',
|
|
||||||
application_id: '',
|
|
||||||
webhook_secret: ''
|
|
||||||
}));
|
|
||||||
setConnectionStatus('idle');
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!isOpen) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
|
||||||
<div className="bg-white rounded-lg shadow-xl w-full max-w-4xl max-h-[90vh] overflow-y-auto">
|
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-center justify-between p-6 border-b">
|
|
||||||
<div className="flex items-center space-x-3">
|
|
||||||
<Zap className="h-6 w-6 text-blue-600" />
|
|
||||||
<h2 className="text-xl font-semibold text-gray-900">
|
|
||||||
{isCreating ? 'Add POS Integration' : 'Edit POS Configuration'}
|
|
||||||
</h2>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={onClose}
|
|
||||||
className="text-gray-400 hover:text-gray-600 transition-colors"
|
|
||||||
>
|
|
||||||
<X className="h-6 w-6" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="p-6 space-y-8">
|
|
||||||
{/* POS System Selection */}
|
|
||||||
<div className="space-y-4">
|
|
||||||
<h3 className="text-lg font-medium text-gray-900 flex items-center">
|
|
||||||
<Settings className="h-5 w-5 mr-2" />
|
|
||||||
POS System Configuration
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
||||||
{SUPPORTED_POS_SYSTEMS.map((system) => (
|
|
||||||
<label
|
|
||||||
key={system.id}
|
|
||||||
className={`relative cursor-pointer rounded-lg border p-4 hover:bg-gray-50 transition-colors ${
|
|
||||||
formData.pos_system === system.id
|
|
||||||
? 'border-blue-500 bg-blue-50'
|
|
||||||
: 'border-gray-300'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="radio"
|
|
||||||
value={system.id}
|
|
||||||
checked={formData.pos_system === system.id}
|
|
||||||
onChange={(e) => handlePOSSystemChange(e.target.value)}
|
|
||||||
className="sr-only"
|
|
||||||
/>
|
|
||||||
<div className="flex items-center space-x-3">
|
|
||||||
<span className="text-2xl">{system.logo}</span>
|
|
||||||
<div>
|
|
||||||
<p className="font-medium text-gray-900">{system.name}</p>
|
|
||||||
<p className="text-sm text-gray-500">{system.description}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{formData.pos_system === system.id && (
|
|
||||||
<CheckCircle className="absolute top-2 right-2 h-5 w-5 text-blue-600" />
|
|
||||||
)}
|
|
||||||
</label>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
{errors.pos_system && (
|
|
||||||
<p className="text-red-600 text-sm">{errors.pos_system}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Basic Configuration */}
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
|
||||||
Provider Name *
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={formData.provider_name}
|
|
||||||
onChange={(e) => handleInputChange('provider_name', e.target.value)}
|
|
||||||
className={`w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 ${
|
|
||||||
errors.provider_name ? 'border-red-500' : 'border-gray-300'
|
|
||||||
}`}
|
|
||||||
placeholder="e.g., Main Store Square POS"
|
|
||||||
/>
|
|
||||||
{errors.provider_name && (
|
|
||||||
<p className="text-red-600 text-sm mt-1">{errors.provider_name}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
|
||||||
Environment
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
value={formData.environment}
|
|
||||||
onChange={(e) => handleInputChange('environment', e.target.value)}
|
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
||||||
>
|
|
||||||
<option value="sandbox">Sandbox (Testing)</option>
|
|
||||||
<option value="production">Production (Live)</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
|
||||||
Location ID
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={formData.location_id || ''}
|
|
||||||
onChange={(e) => handleInputChange('location_id', e.target.value)}
|
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
||||||
placeholder="Optional location identifier"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
|
||||||
Merchant ID
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={formData.merchant_id || ''}
|
|
||||||
onChange={(e) => handleInputChange('merchant_id', e.target.value)}
|
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
||||||
placeholder="Optional merchant identifier"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* API Credentials */}
|
|
||||||
{selectedPOSSystem && (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<h3 className="text-lg font-medium text-gray-900 flex items-center">
|
|
||||||
<Key className="h-5 w-5 mr-2" />
|
|
||||||
API Credentials
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
<div className="bg-yellow-50 border border-yellow-200 rounded-md p-4">
|
|
||||||
<div className="flex">
|
|
||||||
<AlertTriangle className="h-5 w-5 text-yellow-400 mr-2" />
|
|
||||||
<p className="text-sm text-yellow-800">
|
|
||||||
Credentials are encrypted and stored securely. Never share your API keys.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
||||||
{selectedPOSSystem.fields.includes('application_id') && (
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
|
||||||
Application ID *
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={formData.application_id || ''}
|
|
||||||
onChange={(e) => handleInputChange('application_id', e.target.value)}
|
|
||||||
className={`w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 ${
|
|
||||||
errors.application_id ? 'border-red-500' : 'border-gray-300'
|
|
||||||
}`}
|
|
||||||
placeholder="Square Application ID"
|
|
||||||
/>
|
|
||||||
{errors.application_id && (
|
|
||||||
<p className="text-red-600 text-sm mt-1">{errors.application_id}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{selectedPOSSystem.fields.includes('access_token') && (
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
|
||||||
Access Token *
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
value={formData.access_token || ''}
|
|
||||||
onChange={(e) => handleInputChange('access_token', e.target.value)}
|
|
||||||
className={`w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 ${
|
|
||||||
errors.access_token ? 'border-red-500' : 'border-gray-300'
|
|
||||||
}`}
|
|
||||||
placeholder="Square Access Token"
|
|
||||||
/>
|
|
||||||
{errors.access_token && (
|
|
||||||
<p className="text-red-600 text-sm mt-1">{errors.access_token}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{selectedPOSSystem.fields.includes('api_key') && (
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
|
||||||
API Key *
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
value={formData.api_key || ''}
|
|
||||||
onChange={(e) => handleInputChange('api_key', e.target.value)}
|
|
||||||
className={`w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 ${
|
|
||||||
errors.api_key ? 'border-red-500' : 'border-gray-300'
|
|
||||||
}`}
|
|
||||||
placeholder="API Key"
|
|
||||||
/>
|
|
||||||
{errors.api_key && (
|
|
||||||
<p className="text-red-600 text-sm mt-1">{errors.api_key}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{selectedPOSSystem.fields.includes('api_secret') && (
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
|
||||||
API Secret
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
value={formData.api_secret || ''}
|
|
||||||
onChange={(e) => handleInputChange('api_secret', e.target.value)}
|
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
||||||
placeholder="API Secret"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{selectedPOSSystem.fields.includes('webhook_secret') && (
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
|
||||||
Webhook Secret
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
value={formData.webhook_secret || ''}
|
|
||||||
onChange={(e) => handleInputChange('webhook_secret', e.target.value)}
|
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
||||||
placeholder="Webhook verification secret"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Test Connection */}
|
|
||||||
<div className="flex items-center space-x-4">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
onClick={handleTestConnection}
|
|
||||||
disabled={testingConnection}
|
|
||||||
className="flex items-center space-x-2"
|
|
||||||
>
|
|
||||||
{testingConnection ? (
|
|
||||||
<LoadingSpinner size="sm" />
|
|
||||||
) : (
|
|
||||||
<Globe className="h-4 w-4" />
|
|
||||||
)}
|
|
||||||
<span>Test Connection</span>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{connectionStatus === 'success' && (
|
|
||||||
<div className="flex items-center text-green-600">
|
|
||||||
<CheckCircle className="h-5 w-5 mr-2" />
|
|
||||||
<span className="text-sm">Connection successful</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{connectionStatus === 'error' && (
|
|
||||||
<div className="flex items-center text-red-600">
|
|
||||||
<X className="h-5 w-5 mr-2" />
|
|
||||||
<span className="text-sm">Connection failed</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Sync Configuration */}
|
|
||||||
<div className="space-y-4">
|
|
||||||
<h3 className="text-lg font-medium text-gray-900 flex items-center">
|
|
||||||
<RefreshCw className="h-5 w-5 mr-2" />
|
|
||||||
Synchronization Settings
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
|
||||||
Sync Interval (minutes)
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
min="1"
|
|
||||||
max="1440"
|
|
||||||
value={formData.sync_interval_minutes}
|
|
||||||
onChange={(e) => handleInputChange('sync_interval_minutes', e.target.value)}
|
|
||||||
className={`w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 ${
|
|
||||||
errors.sync_interval_minutes ? 'border-red-500' : 'border-gray-300'
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
{errors.sync_interval_minutes && (
|
|
||||||
<p className="text-red-600 text-sm mt-1">{errors.sync_interval_minutes}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-4">
|
|
||||||
<label className="flex items-center space-x-2">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={formData.sync_enabled}
|
|
||||||
onChange={(e) => handleInputChange('sync_enabled', e.target.checked)}
|
|
||||||
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
|
||||||
/>
|
|
||||||
<span className="text-sm font-medium text-gray-700">Enable automatic sync</span>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label className="flex items-center space-x-2">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={formData.auto_sync_transactions}
|
|
||||||
onChange={(e) => handleInputChange('auto_sync_transactions', e.target.checked)}
|
|
||||||
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
|
||||||
/>
|
|
||||||
<span className="text-sm font-medium text-gray-700">Auto-sync transactions</span>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label className="flex items-center space-x-2">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={formData.auto_sync_products}
|
|
||||||
onChange={(e) => handleInputChange('auto_sync_products', e.target.checked)}
|
|
||||||
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
|
||||||
/>
|
|
||||||
<span className="text-sm font-medium text-gray-700">Auto-sync products</span>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label className="flex items-center space-x-2">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={formData.is_active}
|
|
||||||
onChange={(e) => handleInputChange('is_active', e.target.checked)}
|
|
||||||
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
|
||||||
/>
|
|
||||||
<span className="text-sm font-medium text-gray-700">Configuration active</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Notes */}
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
|
||||||
Notes
|
|
||||||
</label>
|
|
||||||
<textarea
|
|
||||||
value={formData.notes || ''}
|
|
||||||
onChange={(e) => handleInputChange('notes', e.target.value)}
|
|
||||||
rows={3}
|
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
||||||
placeholder="Optional notes about this POS configuration..."
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Form Actions */}
|
|
||||||
<div className="flex items-center justify-end space-x-4 pt-6 border-t">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
onClick={onClose}
|
|
||||||
disabled={isLoading}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
disabled={isLoading}
|
|
||||||
className="flex items-center space-x-2"
|
|
||||||
>
|
|
||||||
{isLoading ? (
|
|
||||||
<LoadingSpinner size="sm" />
|
|
||||||
) : (
|
|
||||||
<Database className="h-4 w-4" />
|
|
||||||
)}
|
|
||||||
<span>{isCreating ? 'Create Configuration' : 'Update Configuration'}</span>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default POSConfigurationForm;
|
|
||||||
@@ -1,394 +0,0 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
|
||||||
import {
|
|
||||||
Plus,
|
|
||||||
Settings,
|
|
||||||
Activity,
|
|
||||||
Zap,
|
|
||||||
RefreshCw,
|
|
||||||
AlertTriangle,
|
|
||||||
CheckCircle,
|
|
||||||
Clock,
|
|
||||||
Globe,
|
|
||||||
Database,
|
|
||||||
BarChart3,
|
|
||||||
Filter,
|
|
||||||
Search
|
|
||||||
} from 'lucide-react';
|
|
||||||
|
|
||||||
import Button from '../ui/Button';
|
|
||||||
import Card from '../ui/Card';
|
|
||||||
import LoadingSpinner from '../ui/LoadingSpinner';
|
|
||||||
import POSConfigurationForm from './POSConfigurationForm';
|
|
||||||
import POSConfigurationCard from './POSConfigurationCard';
|
|
||||||
import POSSyncStatus from './POSSyncStatus';
|
|
||||||
|
|
||||||
interface POSConfiguration {
|
|
||||||
id: string;
|
|
||||||
pos_system: string;
|
|
||||||
provider_name: string;
|
|
||||||
is_active: boolean;
|
|
||||||
is_connected: boolean;
|
|
||||||
environment: string;
|
|
||||||
location_id?: string;
|
|
||||||
merchant_id?: string;
|
|
||||||
sync_enabled: boolean;
|
|
||||||
sync_interval_minutes: string;
|
|
||||||
auto_sync_products: boolean;
|
|
||||||
auto_sync_transactions: boolean;
|
|
||||||
last_sync_at?: string;
|
|
||||||
last_successful_sync_at?: string;
|
|
||||||
last_sync_status?: string;
|
|
||||||
health_status: string;
|
|
||||||
created_at: string;
|
|
||||||
updated_at: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SyncSummary {
|
|
||||||
total_configurations: number;
|
|
||||||
active_configurations: number;
|
|
||||||
connected_configurations: number;
|
|
||||||
sync_enabled_configurations: number;
|
|
||||||
last_24h_syncs: number;
|
|
||||||
failed_syncs_24h: number;
|
|
||||||
total_transactions_today: number;
|
|
||||||
total_revenue_today: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const POSManagementPage: React.FC = () => {
|
|
||||||
const [configurations, setConfigurations] = useState<POSConfiguration[]>([]);
|
|
||||||
const [syncSummary, setSyncSummary] = useState<SyncSummary | null>(null);
|
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
|
||||||
const [isFormOpen, setIsFormOpen] = useState(false);
|
|
||||||
const [selectedConfig, setSelectedConfig] = useState<POSConfiguration | null>(null);
|
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
|
||||||
const [filterStatus, setFilterStatus] = useState<'all' | 'active' | 'inactive' | 'connected' | 'disconnected'>('all');
|
|
||||||
const [filterPOSSystem, setFilterPOSSystem] = useState<'all' | 'square' | 'toast' | 'lightspeed'>('all');
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
loadConfigurations();
|
|
||||||
loadSyncSummary();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const loadConfigurations = async () => {
|
|
||||||
try {
|
|
||||||
setIsLoading(true);
|
|
||||||
// TODO: Replace with actual API call
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
||||||
|
|
||||||
// Mock data
|
|
||||||
const mockConfigurations: POSConfiguration[] = [
|
|
||||||
{
|
|
||||||
id: '1',
|
|
||||||
pos_system: 'square',
|
|
||||||
provider_name: 'Main Store Square POS',
|
|
||||||
is_active: true,
|
|
||||||
is_connected: true,
|
|
||||||
environment: 'production',
|
|
||||||
location_id: 'L123456789',
|
|
||||||
sync_enabled: true,
|
|
||||||
sync_interval_minutes: '5',
|
|
||||||
auto_sync_products: true,
|
|
||||||
auto_sync_transactions: true,
|
|
||||||
last_sync_at: '2024-01-15T10:30:00Z',
|
|
||||||
last_successful_sync_at: '2024-01-15T10:30:00Z',
|
|
||||||
last_sync_status: 'success',
|
|
||||||
health_status: 'healthy',
|
|
||||||
created_at: '2024-01-01T00:00:00Z',
|
|
||||||
updated_at: '2024-01-15T10:30:00Z'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '2',
|
|
||||||
pos_system: 'toast',
|
|
||||||
provider_name: 'Bakery Toast System',
|
|
||||||
is_active: true,
|
|
||||||
is_connected: false,
|
|
||||||
environment: 'sandbox',
|
|
||||||
sync_enabled: false,
|
|
||||||
sync_interval_minutes: '10',
|
|
||||||
auto_sync_products: true,
|
|
||||||
auto_sync_transactions: true,
|
|
||||||
last_sync_status: 'failed',
|
|
||||||
health_status: 'unhealthy',
|
|
||||||
created_at: '2024-01-10T00:00:00Z',
|
|
||||||
updated_at: '2024-01-15T09:00:00Z'
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
setConfigurations(mockConfigurations);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error loading configurations:', error);
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const loadSyncSummary = async () => {
|
|
||||||
try {
|
|
||||||
// TODO: Replace with actual API call
|
|
||||||
const mockSummary: SyncSummary = {
|
|
||||||
total_configurations: 2,
|
|
||||||
active_configurations: 2,
|
|
||||||
connected_configurations: 1,
|
|
||||||
sync_enabled_configurations: 1,
|
|
||||||
last_24h_syncs: 45,
|
|
||||||
failed_syncs_24h: 2,
|
|
||||||
total_transactions_today: 156,
|
|
||||||
total_revenue_today: 2847.50
|
|
||||||
};
|
|
||||||
|
|
||||||
setSyncSummary(mockSummary);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error loading sync summary:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCreateConfiguration = () => {
|
|
||||||
setSelectedConfig(null);
|
|
||||||
setIsFormOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleEditConfiguration = (config: POSConfiguration) => {
|
|
||||||
setSelectedConfig(config);
|
|
||||||
setIsFormOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleFormSubmit = async (data: POSConfiguration) => {
|
|
||||||
try {
|
|
||||||
// TODO: Replace with actual API call
|
|
||||||
console.log('Submitting configuration:', data);
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
||||||
|
|
||||||
setIsFormOpen(false);
|
|
||||||
loadConfigurations();
|
|
||||||
loadSyncSummary();
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error submitting configuration:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleTriggerSync = async (configId: string) => {
|
|
||||||
try {
|
|
||||||
// TODO: Replace with actual API call
|
|
||||||
console.log('Triggering sync for configuration:', configId);
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
||||||
loadConfigurations();
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error triggering sync:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleTestConnection = async (configId: string) => {
|
|
||||||
try {
|
|
||||||
// TODO: Replace with actual API call
|
|
||||||
console.log('Testing connection for configuration:', configId);
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
|
||||||
loadConfigurations();
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error testing connection:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const filteredConfigurations = configurations.filter(config => {
|
|
||||||
const matchesSearch = config.provider_name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
|
||||||
config.pos_system.toLowerCase().includes(searchTerm.toLowerCase());
|
|
||||||
|
|
||||||
const matchesStatus = filterStatus === 'all' ||
|
|
||||||
(filterStatus === 'active' && config.is_active) ||
|
|
||||||
(filterStatus === 'inactive' && !config.is_active) ||
|
|
||||||
(filterStatus === 'connected' && config.is_connected) ||
|
|
||||||
(filterStatus === 'disconnected' && !config.is_connected);
|
|
||||||
|
|
||||||
const matchesPOSSystem = filterPOSSystem === 'all' || config.pos_system === filterPOSSystem;
|
|
||||||
|
|
||||||
return matchesSearch && matchesStatus && matchesPOSSystem;
|
|
||||||
});
|
|
||||||
|
|
||||||
const getStatusColor = (status: string) => {
|
|
||||||
switch (status) {
|
|
||||||
case 'healthy': return 'text-green-600';
|
|
||||||
case 'unhealthy': return 'text-red-600';
|
|
||||||
case 'warning': return 'text-yellow-600';
|
|
||||||
default: return 'text-gray-600';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getStatusIcon = (status: string) => {
|
|
||||||
switch (status) {
|
|
||||||
case 'healthy': return CheckCircle;
|
|
||||||
case 'unhealthy': return AlertTriangle;
|
|
||||||
case 'warning': return Clock;
|
|
||||||
default: return Activity;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (isLoading && configurations.length === 0) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-center h-64">
|
|
||||||
<LoadingSpinner size="lg" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-2xl font-bold text-gray-900">POS Integrations</h1>
|
|
||||||
<p className="text-gray-600">Manage your Point of Sale system integrations</p>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
onClick={handleCreateConfiguration}
|
|
||||||
className="flex items-center space-x-2"
|
|
||||||
>
|
|
||||||
<Plus className="h-4 w-4" />
|
|
||||||
<span>Add POS Integration</span>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Summary Cards */}
|
|
||||||
{syncSummary && (
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
|
||||||
<Card className="p-6">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium text-gray-600">Total Integrations</p>
|
|
||||||
<p className="text-2xl font-bold text-gray-900">{syncSummary.total_configurations}</p>
|
|
||||||
</div>
|
|
||||||
<Settings className="h-8 w-8 text-blue-600" />
|
|
||||||
</div>
|
|
||||||
<div className="mt-2 text-sm text-gray-600">
|
|
||||||
{syncSummary.active_configurations} active, {syncSummary.connected_configurations} connected
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card className="p-6">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium text-gray-600">Today's Transactions</p>
|
|
||||||
<p className="text-2xl font-bold text-gray-900">{syncSummary.total_transactions_today}</p>
|
|
||||||
</div>
|
|
||||||
<BarChart3 className="h-8 w-8 text-green-600" />
|
|
||||||
</div>
|
|
||||||
<div className="mt-2 text-sm text-gray-600">
|
|
||||||
€{syncSummary.total_revenue_today.toLocaleString()} revenue
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card className="p-6">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium text-gray-600">24h Syncs</p>
|
|
||||||
<p className="text-2xl font-bold text-gray-900">{syncSummary.last_24h_syncs}</p>
|
|
||||||
</div>
|
|
||||||
<RefreshCw className="h-8 w-8 text-purple-600" />
|
|
||||||
</div>
|
|
||||||
<div className="mt-2 text-sm text-gray-600">
|
|
||||||
{syncSummary.failed_syncs_24h} failed
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card className="p-6">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium text-gray-600">System Health</p>
|
|
||||||
<p className="text-2xl font-bold text-gray-900">
|
|
||||||
{Math.round((syncSummary.connected_configurations / syncSummary.total_configurations) * 100)}%
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Activity className="h-8 w-8 text-orange-600" />
|
|
||||||
</div>
|
|
||||||
<div className="mt-2 text-sm text-gray-600">
|
|
||||||
Overall connection rate
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Filters */}
|
|
||||||
<div className="flex flex-col sm:flex-row gap-4 items-start sm:items-center">
|
|
||||||
<div className="relative flex-1 max-w-md">
|
|
||||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="Search configurations..."
|
|
||||||
value={searchTerm}
|
|
||||||
onChange={(e) => setSearchTerm(e.target.value)}
|
|
||||||
className="pl-10 pr-4 py-2 w-full border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<select
|
|
||||||
value={filterStatus}
|
|
||||||
onChange={(e) => setFilterStatus(e.target.value as any)}
|
|
||||||
className="px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
||||||
>
|
|
||||||
<option value="all">All Status</option>
|
|
||||||
<option value="active">Active</option>
|
|
||||||
<option value="inactive">Inactive</option>
|
|
||||||
<option value="connected">Connected</option>
|
|
||||||
<option value="disconnected">Disconnected</option>
|
|
||||||
</select>
|
|
||||||
|
|
||||||
<select
|
|
||||||
value={filterPOSSystem}
|
|
||||||
onChange={(e) => setFilterPOSSystem(e.target.value as any)}
|
|
||||||
className="px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
||||||
>
|
|
||||||
<option value="all">All Systems</option>
|
|
||||||
<option value="square">Square</option>
|
|
||||||
<option value="toast">Toast</option>
|
|
||||||
<option value="lightspeed">Lightspeed</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Configurations Grid */}
|
|
||||||
{filteredConfigurations.length === 0 ? (
|
|
||||||
<Card className="p-12 text-center">
|
|
||||||
<Zap className="h-12 w-12 text-gray-400 mx-auto mb-4" />
|
|
||||||
<h3 className="text-lg font-medium text-gray-900 mb-2">No POS integrations found</h3>
|
|
||||||
<p className="text-gray-600 mb-4">
|
|
||||||
{configurations.length === 0
|
|
||||||
? "Get started by adding your first POS integration."
|
|
||||||
: "Try adjusting your search or filter criteria."
|
|
||||||
}
|
|
||||||
</p>
|
|
||||||
{configurations.length === 0 && (
|
|
||||||
<Button onClick={handleCreateConfiguration}>
|
|
||||||
Add Your First Integration
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
) : (
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
|
||||||
{filteredConfigurations.map((config) => (
|
|
||||||
<POSConfigurationCard
|
|
||||||
key={config.id}
|
|
||||||
configuration={config}
|
|
||||||
onEdit={handleEditConfiguration}
|
|
||||||
onTriggerSync={handleTriggerSync}
|
|
||||||
onTestConnection={handleTestConnection}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Sync Status Panel */}
|
|
||||||
<POSSyncStatus configurations={configurations} />
|
|
||||||
|
|
||||||
{/* Configuration Form Modal */}
|
|
||||||
<POSConfigurationForm
|
|
||||||
configuration={selectedConfig}
|
|
||||||
isOpen={isFormOpen}
|
|
||||||
isCreating={!selectedConfig}
|
|
||||||
onSubmit={handleFormSubmit}
|
|
||||||
onClose={() => setIsFormOpen(false)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default POSManagementPage;
|
|
||||||
@@ -1,341 +0,0 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
|
||||||
import {
|
|
||||||
RefreshCw,
|
|
||||||
Clock,
|
|
||||||
CheckCircle,
|
|
||||||
AlertTriangle,
|
|
||||||
BarChart3,
|
|
||||||
TrendingUp,
|
|
||||||
Activity,
|
|
||||||
Database
|
|
||||||
} from 'lucide-react';
|
|
||||||
|
|
||||||
import Card from '../ui/Card';
|
|
||||||
import LoadingSpinner from '../ui/LoadingSpinner';
|
|
||||||
|
|
||||||
interface POSConfiguration {
|
|
||||||
id: string;
|
|
||||||
pos_system: string;
|
|
||||||
provider_name: string;
|
|
||||||
last_sync_at?: string;
|
|
||||||
last_sync_status?: string;
|
|
||||||
sync_enabled: boolean;
|
|
||||||
is_connected: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SyncLogEntry {
|
|
||||||
id: string;
|
|
||||||
config_id: string;
|
|
||||||
sync_type: string;
|
|
||||||
status: string;
|
|
||||||
started_at: string;
|
|
||||||
completed_at?: string;
|
|
||||||
records_processed: number;
|
|
||||||
records_created: number;
|
|
||||||
records_updated: number;
|
|
||||||
records_failed: number;
|
|
||||||
error_message?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface POSSyncStatusProps {
|
|
||||||
configurations: POSConfiguration[];
|
|
||||||
}
|
|
||||||
|
|
||||||
const POSSyncStatus: React.FC<POSSyncStatusProps> = ({ configurations }) => {
|
|
||||||
const [syncLogs, setSyncLogs] = useState<SyncLogEntry[]>([]);
|
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
|
||||||
const [selectedTimeframe, setSelectedTimeframe] = useState<'24h' | '7d' | '30d'>('24h');
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
loadSyncLogs();
|
|
||||||
}, [selectedTimeframe]);
|
|
||||||
|
|
||||||
const loadSyncLogs = async () => {
|
|
||||||
try {
|
|
||||||
setIsLoading(true);
|
|
||||||
// TODO: Replace with actual API call
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
||||||
|
|
||||||
// Mock data
|
|
||||||
const mockLogs: SyncLogEntry[] = [
|
|
||||||
{
|
|
||||||
id: '1',
|
|
||||||
config_id: '1',
|
|
||||||
sync_type: 'incremental',
|
|
||||||
status: 'completed',
|
|
||||||
started_at: '2024-01-15T10:30:00Z',
|
|
||||||
completed_at: '2024-01-15T10:32:15Z',
|
|
||||||
records_processed: 45,
|
|
||||||
records_created: 38,
|
|
||||||
records_updated: 7,
|
|
||||||
records_failed: 0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '2',
|
|
||||||
config_id: '1',
|
|
||||||
sync_type: 'incremental',
|
|
||||||
status: 'completed',
|
|
||||||
started_at: '2024-01-15T10:25:00Z',
|
|
||||||
completed_at: '2024-01-15T10:26:30Z',
|
|
||||||
records_processed: 23,
|
|
||||||
records_created: 20,
|
|
||||||
records_updated: 3,
|
|
||||||
records_failed: 0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '3',
|
|
||||||
config_id: '2',
|
|
||||||
sync_type: 'manual',
|
|
||||||
status: 'failed',
|
|
||||||
started_at: '2024-01-15T09:15:00Z',
|
|
||||||
records_processed: 0,
|
|
||||||
records_created: 0,
|
|
||||||
records_updated: 0,
|
|
||||||
records_failed: 0,
|
|
||||||
error_message: 'Authentication failed - invalid API key'
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
setSyncLogs(mockLogs);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error loading sync logs:', error);
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getStatusColor = (status: string) => {
|
|
||||||
switch (status) {
|
|
||||||
case 'completed': return 'text-green-600';
|
|
||||||
case 'failed': return 'text-red-600';
|
|
||||||
case 'in_progress': return 'text-blue-600';
|
|
||||||
case 'cancelled': return 'text-gray-600';
|
|
||||||
default: return 'text-gray-600';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getStatusIcon = (status: string) => {
|
|
||||||
switch (status) {
|
|
||||||
case 'completed': return CheckCircle;
|
|
||||||
case 'failed': return AlertTriangle;
|
|
||||||
case 'in_progress': return RefreshCw;
|
|
||||||
default: return Clock;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatDuration = (startTime: string, endTime?: string) => {
|
|
||||||
if (!endTime) return 'In progress...';
|
|
||||||
|
|
||||||
const start = new Date(startTime);
|
|
||||||
const end = new Date(endTime);
|
|
||||||
const diffMs = end.getTime() - start.getTime();
|
|
||||||
const diffSecs = Math.floor(diffMs / 1000);
|
|
||||||
|
|
||||||
if (diffSecs < 60) return `${diffSecs}s`;
|
|
||||||
const diffMins = Math.floor(diffSecs / 60);
|
|
||||||
const remainingSecs = diffSecs % 60;
|
|
||||||
return `${diffMins}m ${remainingSecs}s`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatTime = (timestamp: string) => {
|
|
||||||
const date = new Date(timestamp);
|
|
||||||
return date.toLocaleTimeString('en-US', {
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit',
|
|
||||||
hour12: false
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const getConfigurationName = (configId: string) => {
|
|
||||||
const config = configurations.find(c => c.id === configId);
|
|
||||||
return config?.provider_name || 'Unknown Configuration';
|
|
||||||
};
|
|
||||||
|
|
||||||
const calculateSyncStats = () => {
|
|
||||||
const stats = {
|
|
||||||
total: syncLogs.length,
|
|
||||||
completed: syncLogs.filter(log => log.status === 'completed').length,
|
|
||||||
failed: syncLogs.filter(log => log.status === 'failed').length,
|
|
||||||
totalRecords: syncLogs.reduce((sum, log) => sum + log.records_processed, 0),
|
|
||||||
totalCreated: syncLogs.reduce((sum, log) => sum + log.records_created, 0),
|
|
||||||
totalUpdated: syncLogs.reduce((sum, log) => sum + log.records_updated, 0),
|
|
||||||
avgDuration: 0
|
|
||||||
};
|
|
||||||
|
|
||||||
const completedLogs = syncLogs.filter(log => log.status === 'completed' && log.completed_at);
|
|
||||||
if (completedLogs.length > 0) {
|
|
||||||
const totalDuration = completedLogs.reduce((sum, log) => {
|
|
||||||
const start = new Date(log.started_at);
|
|
||||||
const end = new Date(log.completed_at!);
|
|
||||||
return sum + (end.getTime() - start.getTime());
|
|
||||||
}, 0);
|
|
||||||
stats.avgDuration = Math.floor(totalDuration / completedLogs.length / 1000);
|
|
||||||
}
|
|
||||||
|
|
||||||
return stats;
|
|
||||||
};
|
|
||||||
|
|
||||||
const stats = calculateSyncStats();
|
|
||||||
const successRate = stats.total > 0 ? Math.round((stats.completed / stats.total) * 100) : 0;
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<Card className="p-6">
|
|
||||||
<div className="flex items-center justify-center h-32">
|
|
||||||
<LoadingSpinner size="lg" />
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
{/* Sync Statistics */}
|
|
||||||
<Card className="p-6">
|
|
||||||
<div className="flex items-center justify-between mb-6">
|
|
||||||
<h3 className="text-lg font-semibold text-gray-900 flex items-center">
|
|
||||||
<BarChart3 className="h-5 w-5 mr-2" />
|
|
||||||
Sync Performance
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
<div className="flex space-x-2">
|
|
||||||
{(['24h', '7d', '30d'] as const).map((timeframe) => (
|
|
||||||
<button
|
|
||||||
key={timeframe}
|
|
||||||
onClick={() => setSelectedTimeframe(timeframe)}
|
|
||||||
className={`px-3 py-1 text-sm rounded-md transition-colors ${
|
|
||||||
selectedTimeframe === timeframe
|
|
||||||
? 'bg-blue-100 text-blue-700'
|
|
||||||
: 'text-gray-600 hover:text-gray-900'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{timeframe}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-6">
|
|
||||||
<div className="text-center">
|
|
||||||
<div className="text-2xl font-bold text-gray-900">{stats.total}</div>
|
|
||||||
<div className="text-sm text-gray-600">Total Syncs</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="text-center">
|
|
||||||
<div className="text-2xl font-bold text-green-600">{successRate}%</div>
|
|
||||||
<div className="text-sm text-gray-600">Success Rate</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="text-center">
|
|
||||||
<div className="text-2xl font-bold text-blue-600">{stats.totalRecords}</div>
|
|
||||||
<div className="text-sm text-gray-600">Records Synced</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="text-center">
|
|
||||||
<div className="text-2xl font-bold text-purple-600">{stats.avgDuration}s</div>
|
|
||||||
<div className="text-sm text-gray-600">Avg Duration</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Recent Sync Logs */}
|
|
||||||
<Card className="p-6">
|
|
||||||
<div className="flex items-center justify-between mb-6">
|
|
||||||
<h3 className="text-lg font-semibold text-gray-900 flex items-center">
|
|
||||||
<Activity className="h-5 w-5 mr-2" />
|
|
||||||
Recent Sync Activity
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={loadSyncLogs}
|
|
||||||
className="flex items-center space-x-1 text-blue-600 hover:text-blue-700 transition-colors"
|
|
||||||
>
|
|
||||||
<RefreshCw className="h-4 w-4" />
|
|
||||||
<span className="text-sm">Refresh</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{syncLogs.length === 0 ? (
|
|
||||||
<div className="text-center py-8 text-gray-500">
|
|
||||||
<Database className="h-12 w-12 mx-auto mb-4 text-gray-400" />
|
|
||||||
<p>No sync activity found for the selected timeframe.</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-4">
|
|
||||||
{syncLogs.slice(0, 10).map((log) => {
|
|
||||||
const StatusIcon = getStatusIcon(log.status);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={log.id}
|
|
||||||
className="flex items-center justify-between p-4 bg-gray-50 rounded-lg"
|
|
||||||
>
|
|
||||||
<div className="flex items-center space-x-4">
|
|
||||||
<div className={`${getStatusColor(log.status)}`}>
|
|
||||||
<StatusIcon className="h-5 w-5" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<div className="font-medium text-gray-900">
|
|
||||||
{getConfigurationName(log.config_id)}
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-gray-600">
|
|
||||||
{log.sync_type} sync • Started at {formatTime(log.started_at)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center space-x-6 text-sm">
|
|
||||||
{log.status === 'completed' && (
|
|
||||||
<>
|
|
||||||
<div className="text-center">
|
|
||||||
<div className="font-medium text-gray-900">{log.records_processed}</div>
|
|
||||||
<div className="text-gray-600">Processed</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="text-center">
|
|
||||||
<div className="font-medium text-green-600">{log.records_created}</div>
|
|
||||||
<div className="text-gray-600">Created</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="text-center">
|
|
||||||
<div className="font-medium text-blue-600">{log.records_updated}</div>
|
|
||||||
<div className="text-gray-600">Updated</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="text-center">
|
|
||||||
<div className="font-medium text-gray-900">
|
|
||||||
{formatDuration(log.started_at, log.completed_at)}
|
|
||||||
</div>
|
|
||||||
<div className="text-gray-600">Duration</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{log.status === 'failed' && log.error_message && (
|
|
||||||
<div className="max-w-xs">
|
|
||||||
<div className="font-medium text-red-600">Failed</div>
|
|
||||||
<div className="text-gray-600 text-xs truncate">
|
|
||||||
{log.error_message}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{log.status === 'in_progress' && (
|
|
||||||
<div className="text-center">
|
|
||||||
<LoadingSpinner size="sm" />
|
|
||||||
<div className="text-gray-600">Running</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default POSSyncStatus;
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
export { default as POSConfigurationForm } from './POSConfigurationForm';
|
|
||||||
export { default as POSConfigurationCard } from './POSConfigurationCard';
|
|
||||||
export { default as POSManagementPage } from './POSManagementPage';
|
|
||||||
export { default as POSSyncStatus } from './POSSyncStatus';
|
|
||||||
@@ -1,216 +0,0 @@
|
|||||||
// ================================================================
|
|
||||||
// frontend/src/components/procurement/CriticalRequirements.tsx
|
|
||||||
// ================================================================
|
|
||||||
/**
|
|
||||||
* Critical Requirements Component
|
|
||||||
* Displays urgent procurement requirements that need immediate attention
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React from 'react';
|
|
||||||
import type { ProcurementRequirement } from '@/api/types/procurement';
|
|
||||||
import { Priority, RequirementStatus } from '@/api/types/procurement';
|
|
||||||
|
|
||||||
export interface CriticalRequirementsProps {
|
|
||||||
requirements: ProcurementRequirement[];
|
|
||||||
onViewDetails?: (requirementId: string) => void;
|
|
||||||
onUpdateStatus?: (requirementId: string, status: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const CriticalRequirements: React.FC<CriticalRequirementsProps> = ({
|
|
||||||
requirements,
|
|
||||||
onViewDetails,
|
|
||||||
onUpdateStatus,
|
|
||||||
}) => {
|
|
||||||
const formatCurrency = (amount: number | undefined) => {
|
|
||||||
if (!amount) return 'N/A';
|
|
||||||
return new Intl.NumberFormat('es-ES', {
|
|
||||||
style: 'currency',
|
|
||||||
currency: 'EUR'
|
|
||||||
}).format(amount);
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatDate = (dateString: string) => {
|
|
||||||
const date = new Date(dateString);
|
|
||||||
const today = new Date();
|
|
||||||
const diffTime = date.getTime() - today.getTime();
|
|
||||||
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
|
|
||||||
|
|
||||||
if (diffDays < 0) {
|
|
||||||
return `Overdue by ${Math.abs(diffDays)} days`;
|
|
||||||
} else if (diffDays === 0) {
|
|
||||||
return 'Due today';
|
|
||||||
} else if (diffDays === 1) {
|
|
||||||
return 'Due tomorrow';
|
|
||||||
} else {
|
|
||||||
return `Due in ${diffDays} days`;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getStatusColor = (status: string) => {
|
|
||||||
const colors = {
|
|
||||||
[RequirementStatus.PENDING]: 'bg-yellow-100 text-yellow-800',
|
|
||||||
[RequirementStatus.APPROVED]: 'bg-blue-100 text-blue-800',
|
|
||||||
[RequirementStatus.ORDERED]: 'bg-purple-100 text-purple-800',
|
|
||||||
[RequirementStatus.PARTIALLY_RECEIVED]: 'bg-orange-100 text-orange-800',
|
|
||||||
[RequirementStatus.RECEIVED]: 'bg-green-100 text-green-800',
|
|
||||||
[RequirementStatus.CANCELLED]: 'bg-red-100 text-red-800',
|
|
||||||
};
|
|
||||||
return colors[status as keyof typeof colors] || 'bg-gray-100 text-gray-800';
|
|
||||||
};
|
|
||||||
|
|
||||||
const getDueDateColor = (dateString: string) => {
|
|
||||||
const date = new Date(dateString);
|
|
||||||
const today = new Date();
|
|
||||||
const diffTime = date.getTime() - today.getTime();
|
|
||||||
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
|
|
||||||
|
|
||||||
if (diffDays < 0) return 'text-red-600 font-medium'; // Overdue
|
|
||||||
if (diffDays <= 1) return 'text-orange-600 font-medium'; // Due soon
|
|
||||||
return 'text-gray-600';
|
|
||||||
};
|
|
||||||
|
|
||||||
const getStockLevelColor = (current: number, needed: number) => {
|
|
||||||
const ratio = current / needed;
|
|
||||||
if (ratio <= 0.1) return 'text-red-600 font-medium'; // Critical
|
|
||||||
if (ratio <= 0.3) return 'text-orange-600 font-medium'; // Low
|
|
||||||
return 'text-gray-600';
|
|
||||||
};
|
|
||||||
|
|
||||||
if (requirements.length === 0) {
|
|
||||||
return (
|
|
||||||
<div className="text-center py-8 text-gray-500">
|
|
||||||
<p>No critical requirements at this time</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-3">
|
|
||||||
{requirements.map((requirement) => (
|
|
||||||
<div
|
|
||||||
key={requirement.id}
|
|
||||||
className="border border-red-200 rounded-lg p-4 bg-red-50 hover:bg-red-100 transition-colors"
|
|
||||||
>
|
|
||||||
<div className="flex items-start justify-between">
|
|
||||||
<div className="flex-1">
|
|
||||||
<div className="flex items-center space-x-2 mb-2">
|
|
||||||
<h4 className="font-medium text-gray-900">
|
|
||||||
{requirement.product_name}
|
|
||||||
</h4>
|
|
||||||
<span className={`px-2 py-1 rounded-full text-xs font-medium ${getStatusColor(requirement.status)}`}>
|
|
||||||
{requirement.status.replace('_', ' ').toUpperCase()}
|
|
||||||
</span>
|
|
||||||
<span className="px-2 py-1 rounded-full text-xs font-medium bg-red-100 text-red-700">
|
|
||||||
CRITICAL
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 text-sm">
|
|
||||||
<div>
|
|
||||||
<span className="text-gray-500">Required:</span>
|
|
||||||
<div className="font-medium">
|
|
||||||
{requirement.net_requirement} {requirement.unit_of_measure}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<span className="text-gray-500">Current Stock:</span>
|
|
||||||
<div className={getStockLevelColor(requirement.current_stock_level, requirement.net_requirement)}>
|
|
||||||
{requirement.current_stock_level} {requirement.unit_of_measure}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<span className="text-gray-500">Due Date:</span>
|
|
||||||
<div className={getDueDateColor(requirement.required_by_date)}>
|
|
||||||
{formatDate(requirement.required_by_date)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<span className="text-gray-500">Est. Cost:</span>
|
|
||||||
<div className="font-medium">
|
|
||||||
{formatCurrency(requirement.estimated_total_cost)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{requirement.supplier_name && (
|
|
||||||
<div className="mt-2 text-sm">
|
|
||||||
<span className="text-gray-500">Supplier:</span>
|
|
||||||
<span className="ml-1 font-medium">{requirement.supplier_name}</span>
|
|
||||||
{requirement.supplier_lead_time_days && (
|
|
||||||
<span className="ml-2 text-gray-500">
|
|
||||||
({requirement.supplier_lead_time_days} days lead time)
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{requirement.special_requirements && (
|
|
||||||
<div className="mt-2 p-2 bg-yellow-50 rounded border border-yellow-200">
|
|
||||||
<span className="text-xs text-yellow-700 font-medium">Special Requirements:</span>
|
|
||||||
<p className="text-xs text-yellow-600 mt-1">
|
|
||||||
{requirement.special_requirements}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col space-y-2 ml-4">
|
|
||||||
{requirement.status === RequirementStatus.PENDING && (
|
|
||||||
<button
|
|
||||||
onClick={() => onUpdateStatus?.(requirement.id, RequirementStatus.APPROVED)}
|
|
||||||
className="px-3 py-1 bg-blue-600 text-white text-xs rounded hover:bg-blue-700 transition-colors"
|
|
||||||
>
|
|
||||||
Approve
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{requirement.status === RequirementStatus.APPROVED && (
|
|
||||||
<button
|
|
||||||
onClick={() => onUpdateStatus?.(requirement.id, RequirementStatus.ORDERED)}
|
|
||||||
className="px-3 py-1 bg-purple-600 text-white text-xs rounded hover:bg-purple-700 transition-colors"
|
|
||||||
>
|
|
||||||
Order Now
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={() => onViewDetails?.(requirement.id)}
|
|
||||||
className="px-3 py-1 bg-gray-200 text-gray-700 text-xs rounded hover:bg-gray-300 transition-colors"
|
|
||||||
>
|
|
||||||
Details
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Progress indicator for ordered items */}
|
|
||||||
{requirement.status === RequirementStatus.ORDERED && requirement.ordered_quantity > 0 && (
|
|
||||||
<div className="mt-3 pt-3 border-t border-red-200">
|
|
||||||
<div className="flex justify-between text-xs text-gray-600 mb-1">
|
|
||||||
<span>Order Progress</span>
|
|
||||||
<span>
|
|
||||||
{requirement.received_quantity} / {requirement.ordered_quantity} {requirement.unit_of_measure}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="w-full bg-gray-200 rounded-full h-2">
|
|
||||||
<div
|
|
||||||
className="bg-blue-600 h-2 rounded-full transition-all"
|
|
||||||
style={{
|
|
||||||
width: `${Math.min(100, (requirement.received_quantity / requirement.ordered_quantity) * 100)}%`
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{requirement.expected_delivery_date && (
|
|
||||||
<div className="text-xs text-gray-500 mt-1">
|
|
||||||
Expected: {formatDate(requirement.expected_delivery_date)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,202 +0,0 @@
|
|||||||
// ================================================================
|
|
||||||
// frontend/src/components/procurement/GeneratePlanModal.tsx
|
|
||||||
// ================================================================
|
|
||||||
/**
|
|
||||||
* Generate Plan Modal Component
|
|
||||||
* Modal for configuring and generating new procurement plans
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React, { useState } from 'react';
|
|
||||||
import { Button } from '@/components/ui/Button';
|
|
||||||
import { Input } from '@/components/ui/Input';
|
|
||||||
import type { GeneratePlanRequest } from '@/api/types/procurement';
|
|
||||||
|
|
||||||
export interface GeneratePlanModalProps {
|
|
||||||
onGenerate: (request: GeneratePlanRequest) => void;
|
|
||||||
onClose: () => void;
|
|
||||||
isGenerating: boolean;
|
|
||||||
error?: Error | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const GeneratePlanModal: React.FC<GeneratePlanModalProps> = ({
|
|
||||||
onGenerate,
|
|
||||||
onClose,
|
|
||||||
isGenerating,
|
|
||||||
error,
|
|
||||||
}) => {
|
|
||||||
const [formData, setFormData] = useState<GeneratePlanRequest>({
|
|
||||||
plan_date: new Date().toISOString().split('T')[0], // Today
|
|
||||||
force_regenerate: false,
|
|
||||||
planning_horizon_days: 14,
|
|
||||||
include_safety_stock: true,
|
|
||||||
safety_stock_percentage: 20,
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleSubmit = (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
onGenerate(formData);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleInputChange = (field: keyof GeneratePlanRequest, value: any) => {
|
|
||||||
setFormData(prev => ({
|
|
||||||
...prev,
|
|
||||||
[field]: value,
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
|
||||||
<div className="bg-white rounded-lg p-6 w-full max-w-md mx-4">
|
|
||||||
<div className="flex items-center justify-between mb-4">
|
|
||||||
<h2 className="text-lg font-semibold text-gray-900">
|
|
||||||
Generate Procurement Plan
|
|
||||||
</h2>
|
|
||||||
<button
|
|
||||||
onClick={onClose}
|
|
||||||
disabled={isGenerating}
|
|
||||||
className="text-gray-400 hover:text-gray-600"
|
|
||||||
>
|
|
||||||
✕
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
|
||||||
{/* Plan Date */}
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
||||||
Plan Date
|
|
||||||
</label>
|
|
||||||
<Input
|
|
||||||
type="date"
|
|
||||||
value={formData.plan_date || ''}
|
|
||||||
onChange={(e) => handleInputChange('plan_date', e.target.value)}
|
|
||||||
disabled={isGenerating}
|
|
||||||
className="w-full"
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-gray-500 mt-1">
|
|
||||||
Date for which to generate the procurement plan
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Planning Horizon */}
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
||||||
Planning Horizon (days)
|
|
||||||
</label>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
min="1"
|
|
||||||
max="30"
|
|
||||||
value={formData.planning_horizon_days || 14}
|
|
||||||
onChange={(e) => handleInputChange('planning_horizon_days', parseInt(e.target.value))}
|
|
||||||
disabled={isGenerating}
|
|
||||||
className="w-full"
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-gray-500 mt-1">
|
|
||||||
Number of days to plan ahead (1-30)
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Safety Stock */}
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center mb-2">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
id="include_safety_stock"
|
|
||||||
checked={formData.include_safety_stock || false}
|
|
||||||
onChange={(e) => handleInputChange('include_safety_stock', e.target.checked)}
|
|
||||||
disabled={isGenerating}
|
|
||||||
className="h-4 w-4 text-blue-600 rounded border-gray-300"
|
|
||||||
/>
|
|
||||||
<label htmlFor="include_safety_stock" className="ml-2 text-sm font-medium text-gray-700">
|
|
||||||
Include Safety Stock
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{formData.include_safety_stock && (
|
|
||||||
<div className="ml-6">
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
||||||
Safety Stock Percentage
|
|
||||||
</label>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
min="0"
|
|
||||||
max="100"
|
|
||||||
step="5"
|
|
||||||
value={formData.safety_stock_percentage || 20}
|
|
||||||
onChange={(e) => handleInputChange('safety_stock_percentage', parseFloat(e.target.value))}
|
|
||||||
disabled={isGenerating}
|
|
||||||
className="w-full"
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-gray-500 mt-1">
|
|
||||||
Additional buffer stock as percentage of demand (0-100%)
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Force Regenerate */}
|
|
||||||
<div className="flex items-center">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
id="force_regenerate"
|
|
||||||
checked={formData.force_regenerate || false}
|
|
||||||
onChange={(e) => handleInputChange('force_regenerate', e.target.checked)}
|
|
||||||
disabled={isGenerating}
|
|
||||||
className="h-4 w-4 text-blue-600 rounded border-gray-300"
|
|
||||||
/>
|
|
||||||
<label htmlFor="force_regenerate" className="ml-2 text-sm font-medium text-gray-700">
|
|
||||||
Force Regenerate
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-gray-500">
|
|
||||||
Regenerate plan even if one already exists for this date
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{/* Error Display */}
|
|
||||||
{error && (
|
|
||||||
<div className="bg-red-50 border border-red-200 rounded p-3">
|
|
||||||
<p className="text-red-600 text-sm">
|
|
||||||
{error.message || 'Failed to generate plan'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Action Buttons */}
|
|
||||||
<div className="flex items-center justify-end space-x-3 pt-4">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
onClick={onClose}
|
|
||||||
disabled={isGenerating}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
disabled={isGenerating}
|
|
||||||
>
|
|
||||||
{isGenerating ? 'Generating...' : 'Generate Plan'}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
{/* Generation Progress */}
|
|
||||||
{isGenerating && (
|
|
||||||
<div className="mt-4 p-3 bg-blue-50 rounded">
|
|
||||||
<div className="flex items-center">
|
|
||||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-blue-600 mr-2"></div>
|
|
||||||
<span className="text-sm text-blue-700">
|
|
||||||
Generating procurement plan...
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="mt-2 text-xs text-blue-600">
|
|
||||||
This may take a few moments while we analyze inventory and forecast demand.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,268 +0,0 @@
|
|||||||
// ================================================================
|
|
||||||
// frontend/src/components/procurement/ProcurementDashboard.tsx
|
|
||||||
// ================================================================
|
|
||||||
/**
|
|
||||||
* Procurement Dashboard Component
|
|
||||||
* Main dashboard for procurement planning functionality
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React, { useState } from 'react';
|
|
||||||
import { Card } from '@/components/ui/Card';
|
|
||||||
import { Button } from '@/components/ui/Button';
|
|
||||||
import { LoadingSpinner } from '@/components/ui/LoadingSpinner';
|
|
||||||
import {
|
|
||||||
useProcurementPlanDashboard,
|
|
||||||
useProcurementPlanActions
|
|
||||||
} from '@/api/hooks/useProcurement';
|
|
||||||
import type {
|
|
||||||
ProcurementPlan,
|
|
||||||
ProcurementRequirement,
|
|
||||||
GeneratePlanRequest
|
|
||||||
} from '@/api/types/procurement';
|
|
||||||
import { ProcurementPlanCard } from './ProcurementPlanCard';
|
|
||||||
import { ProcurementSummary } from './ProcurementSummary';
|
|
||||||
import { CriticalRequirements } from './CriticalRequirements';
|
|
||||||
import { GeneratePlanModal } from './GeneratePlanModal';
|
|
||||||
|
|
||||||
export interface ProcurementDashboardProps {
|
|
||||||
showFilters?: boolean;
|
|
||||||
refreshInterval?: number;
|
|
||||||
onPlanGenerated?: (plan: ProcurementPlan) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ProcurementDashboard: React.FC<ProcurementDashboardProps> = ({
|
|
||||||
showFilters = true,
|
|
||||||
refreshInterval = 5 * 60 * 1000, // 5 minutes
|
|
||||||
onPlanGenerated,
|
|
||||||
}) => {
|
|
||||||
const [showGenerateModal, setShowGenerateModal] = useState(false);
|
|
||||||
|
|
||||||
const {
|
|
||||||
currentPlan,
|
|
||||||
dashboard,
|
|
||||||
criticalRequirements,
|
|
||||||
health,
|
|
||||||
isLoading,
|
|
||||||
error,
|
|
||||||
refetchAll
|
|
||||||
} = useProcurementPlanDashboard();
|
|
||||||
|
|
||||||
const {
|
|
||||||
generatePlan,
|
|
||||||
updateStatus,
|
|
||||||
triggerScheduler,
|
|
||||||
isGenerating,
|
|
||||||
generateError
|
|
||||||
} = useProcurementPlanActions();
|
|
||||||
|
|
||||||
const handleGeneratePlan = (request: GeneratePlanRequest) => {
|
|
||||||
generatePlan(request, {
|
|
||||||
onSuccess: (response) => {
|
|
||||||
if (response.success && response.plan) {
|
|
||||||
onPlanGenerated?.(response.plan);
|
|
||||||
setShowGenerateModal(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleStatusUpdate = (planId: string, status: string) => {
|
|
||||||
updateStatus({ planId, status });
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleTriggerScheduler = () => {
|
|
||||||
triggerScheduler();
|
|
||||||
};
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-center py-8">
|
|
||||||
<LoadingSpinner size="lg" />
|
|
||||||
<span className="ml-2">Loading procurement dashboard...</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return (
|
|
||||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
|
|
||||||
<h3 className="text-red-800 font-medium">Error Loading Dashboard</h3>
|
|
||||||
<p className="text-red-600 mt-1">
|
|
||||||
{error.message || 'Unable to load procurement dashboard data'}
|
|
||||||
</p>
|
|
||||||
<Button
|
|
||||||
onClick={refetchAll}
|
|
||||||
className="mt-2"
|
|
||||||
variant="outline"
|
|
||||||
>
|
|
||||||
Retry
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const dashboardData = dashboard.data;
|
|
||||||
const currentPlanData = currentPlan.data;
|
|
||||||
const criticalReqs = criticalRequirements.data || [];
|
|
||||||
const serviceHealth = health.data;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
{/* Header with Actions */}
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-2xl font-bold text-gray-900">
|
|
||||||
Procurement Planning
|
|
||||||
</h1>
|
|
||||||
<p className="text-gray-600 mt-1">
|
|
||||||
Manage daily procurement plans and requirements
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center space-x-3">
|
|
||||||
{serviceHealth && !serviceHealth.procurement_enabled && (
|
|
||||||
<div className="bg-yellow-100 border border-yellow-300 rounded px-3 py-1">
|
|
||||||
<span className="text-yellow-800 text-sm">
|
|
||||||
Service Disabled
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Button
|
|
||||||
onClick={handleTriggerScheduler}
|
|
||||||
variant="outline"
|
|
||||||
disabled={isGenerating}
|
|
||||||
>
|
|
||||||
Run Scheduler
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
onClick={() => setShowGenerateModal(true)}
|
|
||||||
disabled={isGenerating}
|
|
||||||
>
|
|
||||||
{isGenerating ? 'Generating...' : 'Generate Plan'}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Current Plan Section */}
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
|
||||||
<div className="lg:col-span-2">
|
|
||||||
<Card className="p-6">
|
|
||||||
<div className="flex items-center justify-between mb-4">
|
|
||||||
<h2 className="text-lg font-semibold">Today's Procurement Plan</h2>
|
|
||||||
<Button
|
|
||||||
onClick={refetchAll}
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
>
|
|
||||||
Refresh
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{currentPlanData ? (
|
|
||||||
<ProcurementPlanCard
|
|
||||||
plan={currentPlanData}
|
|
||||||
onUpdateStatus={handleStatusUpdate}
|
|
||||||
showActions={true}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="text-center py-8 text-gray-500">
|
|
||||||
<p>No procurement plan for today</p>
|
|
||||||
<Button
|
|
||||||
onClick={() => setShowGenerateModal(true)}
|
|
||||||
className="mt-2"
|
|
||||||
size="sm"
|
|
||||||
>
|
|
||||||
Generate Plan
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Summary Statistics */}
|
|
||||||
<div>
|
|
||||||
<Card className="p-6">
|
|
||||||
<h2 className="text-lg font-semibold mb-4">Summary</h2>
|
|
||||||
{dashboardData?.summary ? (
|
|
||||||
<ProcurementSummary summary={dashboardData.summary} />
|
|
||||||
) : (
|
|
||||||
<div className="text-gray-500">No summary data available</div>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Critical Requirements */}
|
|
||||||
{criticalReqs.length > 0 && (
|
|
||||||
<Card className="p-6">
|
|
||||||
<h2 className="text-lg font-semibold mb-4 text-red-600">
|
|
||||||
Critical Requirements ({criticalReqs.length})
|
|
||||||
</h2>
|
|
||||||
<CriticalRequirements requirements={criticalReqs} />
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Additional Dashboard Widgets */}
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
|
||||||
{/* Upcoming Deliveries */}
|
|
||||||
{dashboardData?.upcoming_deliveries?.length > 0 && (
|
|
||||||
<Card className="p-6">
|
|
||||||
<h3 className="text-md font-semibold mb-3">Upcoming Deliveries</h3>
|
|
||||||
<div className="space-y-2">
|
|
||||||
{dashboardData.upcoming_deliveries.slice(0, 5).map((delivery, index) => (
|
|
||||||
<div key={index} className="flex justify-between text-sm">
|
|
||||||
<span>{delivery.product_name}</span>
|
|
||||||
<span className="text-gray-500">{delivery.expected_date}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Low Stock Alerts */}
|
|
||||||
{dashboardData?.low_stock_alerts?.length > 0 && (
|
|
||||||
<Card className="p-6">
|
|
||||||
<h3 className="text-md font-semibold mb-3 text-orange-600">
|
|
||||||
Low Stock Alerts
|
|
||||||
</h3>
|
|
||||||
<div className="space-y-2">
|
|
||||||
{dashboardData.low_stock_alerts.slice(0, 5).map((alert, index) => (
|
|
||||||
<div key={index} className="flex justify-between text-sm">
|
|
||||||
<span>{alert.product_name}</span>
|
|
||||||
<span className="text-orange-600">{alert.current_stock}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Performance Metrics */}
|
|
||||||
{dashboardData?.performance_metrics && (
|
|
||||||
<Card className="p-6">
|
|
||||||
<h3 className="text-md font-semibold mb-3">Performance</h3>
|
|
||||||
<div className="space-y-3">
|
|
||||||
{Object.entries(dashboardData.performance_metrics).map(([key, value]) => (
|
|
||||||
<div key={key} className="flex justify-between text-sm">
|
|
||||||
<span className="capitalize">{key.replace('_', ' ')}</span>
|
|
||||||
<span className="font-medium">{value as string}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Generate Plan Modal */}
|
|
||||||
{showGenerateModal && (
|
|
||||||
<GeneratePlanModal
|
|
||||||
onGenerate={handleGeneratePlan}
|
|
||||||
onClose={() => setShowGenerateModal(false)}
|
|
||||||
isGenerating={isGenerating}
|
|
||||||
error={generateError}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,235 +0,0 @@
|
|||||||
// ================================================================
|
|
||||||
// frontend/src/components/procurement/ProcurementPlanCard.tsx
|
|
||||||
// ================================================================
|
|
||||||
/**
|
|
||||||
* Procurement Plan Card Component
|
|
||||||
* Displays a procurement plan with key information and actions
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React from 'react';
|
|
||||||
import { Card } from '@/components/ui/Card';
|
|
||||||
import { Button } from '@/components/ui/Button';
|
|
||||||
import type { ProcurementPlan } from '@/api/types/procurement';
|
|
||||||
import { PlanStatus, Priority } from '@/api/types/procurement';
|
|
||||||
|
|
||||||
export interface ProcurementPlanCardProps {
|
|
||||||
plan: ProcurementPlan;
|
|
||||||
onViewDetails?: (planId: string) => void;
|
|
||||||
onUpdateStatus?: (planId: string, status: string) => void;
|
|
||||||
showActions?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ProcurementPlanCard: React.FC<ProcurementPlanCardProps> = ({
|
|
||||||
plan,
|
|
||||||
onViewDetails,
|
|
||||||
onUpdateStatus,
|
|
||||||
showActions = false,
|
|
||||||
}) => {
|
|
||||||
const getStatusColor = (status: string) => {
|
|
||||||
const colors = {
|
|
||||||
[PlanStatus.DRAFT]: 'bg-gray-100 text-gray-800',
|
|
||||||
[PlanStatus.PENDING_APPROVAL]: 'bg-yellow-100 text-yellow-800',
|
|
||||||
[PlanStatus.APPROVED]: 'bg-blue-100 text-blue-800',
|
|
||||||
[PlanStatus.IN_EXECUTION]: 'bg-green-100 text-green-800',
|
|
||||||
[PlanStatus.COMPLETED]: 'bg-green-100 text-green-800',
|
|
||||||
[PlanStatus.CANCELLED]: 'bg-red-100 text-red-800',
|
|
||||||
};
|
|
||||||
return colors[status as keyof typeof colors] || 'bg-gray-100 text-gray-800';
|
|
||||||
};
|
|
||||||
|
|
||||||
const getPriorityColor = (priority: string) => {
|
|
||||||
const colors = {
|
|
||||||
[Priority.CRITICAL]: 'text-red-600',
|
|
||||||
[Priority.HIGH]: 'text-orange-600',
|
|
||||||
[Priority.NORMAL]: 'text-blue-600',
|
|
||||||
[Priority.LOW]: 'text-gray-600',
|
|
||||||
};
|
|
||||||
return colors[priority as keyof typeof colors] || 'text-gray-600';
|
|
||||||
};
|
|
||||||
|
|
||||||
const getRiskColor = (risk: string) => {
|
|
||||||
const colors = {
|
|
||||||
'critical': 'text-red-600',
|
|
||||||
'high': 'text-orange-600',
|
|
||||||
'medium': 'text-yellow-600',
|
|
||||||
'low': 'text-green-600',
|
|
||||||
};
|
|
||||||
return colors[risk as keyof typeof colors] || 'text-gray-600';
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatCurrency = (amount: number) => {
|
|
||||||
return new Intl.NumberFormat('es-ES', {
|
|
||||||
style: 'currency',
|
|
||||||
currency: 'EUR'
|
|
||||||
}).format(amount);
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatDate = (dateString: string) => {
|
|
||||||
return new Date(dateString).toLocaleDateString('es-ES', {
|
|
||||||
day: 'numeric',
|
|
||||||
month: 'short',
|
|
||||||
year: 'numeric'
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const nextStatusOptions = () => {
|
|
||||||
const options = {
|
|
||||||
[PlanStatus.DRAFT]: [PlanStatus.PENDING_APPROVAL, PlanStatus.CANCELLED],
|
|
||||||
[PlanStatus.PENDING_APPROVAL]: [PlanStatus.APPROVED, PlanStatus.CANCELLED],
|
|
||||||
[PlanStatus.APPROVED]: [PlanStatus.IN_EXECUTION, PlanStatus.CANCELLED],
|
|
||||||
[PlanStatus.IN_EXECUTION]: [PlanStatus.COMPLETED, PlanStatus.CANCELLED],
|
|
||||||
[PlanStatus.COMPLETED]: [],
|
|
||||||
[PlanStatus.CANCELLED]: [],
|
|
||||||
};
|
|
||||||
return options[plan.status as keyof typeof options] || [];
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card className="border border-gray-200">
|
|
||||||
<div className="p-6">
|
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-start justify-between mb-4">
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center space-x-3">
|
|
||||||
<h3 className="text-lg font-semibold text-gray-900">
|
|
||||||
{plan.plan_number}
|
|
||||||
</h3>
|
|
||||||
<span className={`px-2 py-1 rounded-full text-xs font-medium ${getStatusColor(plan.status)}`}>
|
|
||||||
{plan.status.replace('_', ' ').toUpperCase()}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-gray-600 mt-1">
|
|
||||||
Plan Date: {formatDate(plan.plan_date)} |
|
|
||||||
Period: {formatDate(plan.plan_period_start)} - {formatDate(plan.plan_period_end)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="text-right">
|
|
||||||
<div className={`text-sm font-medium ${getPriorityColor(plan.priority)}`}>
|
|
||||||
{plan.priority.toUpperCase()} Priority
|
|
||||||
</div>
|
|
||||||
<div className={`text-xs ${getRiskColor(plan.supply_risk_level)}`}>
|
|
||||||
{plan.supply_risk_level.toUpperCase()} Risk
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Key Metrics */}
|
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-4">
|
|
||||||
<div className="text-center">
|
|
||||||
<div className="text-2xl font-bold text-blue-600">
|
|
||||||
{plan.total_requirements}
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-gray-500">Requirements</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="text-center">
|
|
||||||
<div className="text-2xl font-bold text-green-600">
|
|
||||||
{formatCurrency(plan.total_estimated_cost)}
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-gray-500">Est. Cost</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="text-center">
|
|
||||||
<div className="text-2xl font-bold text-purple-600">
|
|
||||||
{plan.primary_suppliers_count}
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-gray-500">Suppliers</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="text-center">
|
|
||||||
<div className="text-2xl font-bold text-orange-600">
|
|
||||||
{plan.safety_stock_buffer}%
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-gray-500">Safety Buffer</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Requirements Summary */}
|
|
||||||
{plan.requirements && plan.requirements.length > 0 && (
|
|
||||||
<div className="mb-4">
|
|
||||||
<h4 className="text-sm font-medium text-gray-700 mb-2">
|
|
||||||
Top Requirements ({plan.requirements.length} total)
|
|
||||||
</h4>
|
|
||||||
<div className="space-y-1">
|
|
||||||
{plan.requirements.slice(0, 3).map((req) => (
|
|
||||||
<div key={req.id} className="flex justify-between items-center text-sm">
|
|
||||||
<span className="truncate">{req.product_name}</span>
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<span className="text-gray-500">
|
|
||||||
{req.net_requirement} {req.unit_of_measure}
|
|
||||||
</span>
|
|
||||||
<span className={`px-1 py-0.5 rounded text-xs ${
|
|
||||||
req.priority === Priority.CRITICAL ? 'bg-red-100 text-red-700' :
|
|
||||||
req.priority === Priority.HIGH ? 'bg-orange-100 text-orange-700' :
|
|
||||||
'bg-gray-100 text-gray-700'
|
|
||||||
}`}>
|
|
||||||
{req.priority}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{plan.requirements.length > 3 && (
|
|
||||||
<div className="text-xs text-gray-500 text-center">
|
|
||||||
+{plan.requirements.length - 3} more requirements
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Performance Metrics */}
|
|
||||||
{(plan.fulfillment_rate || plan.on_time_delivery_rate) && (
|
|
||||||
<div className="flex items-center space-x-4 text-sm text-gray-600 mb-4">
|
|
||||||
{plan.fulfillment_rate && (
|
|
||||||
<span>Fulfillment: {plan.fulfillment_rate}%</span>
|
|
||||||
)}
|
|
||||||
{plan.on_time_delivery_rate && (
|
|
||||||
<span>On-time: {plan.on_time_delivery_rate}%</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Actions */}
|
|
||||||
{showActions && (
|
|
||||||
<div className="flex items-center justify-between pt-4 border-t border-gray-100">
|
|
||||||
<div className="flex space-x-2">
|
|
||||||
{nextStatusOptions().map((status) => (
|
|
||||||
<Button
|
|
||||||
key={status}
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => onUpdateStatus?.(plan.id, status)}
|
|
||||||
>
|
|
||||||
{status === PlanStatus.PENDING_APPROVAL && 'Submit for Approval'}
|
|
||||||
{status === PlanStatus.APPROVED && 'Approve'}
|
|
||||||
{status === PlanStatus.IN_EXECUTION && 'Start Execution'}
|
|
||||||
{status === PlanStatus.COMPLETED && 'Mark Complete'}
|
|
||||||
{status === PlanStatus.CANCELLED && 'Cancel'}
|
|
||||||
</Button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => onViewDetails?.(plan.id)}
|
|
||||||
>
|
|
||||||
View Details →
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Special Requirements */}
|
|
||||||
{plan.special_requirements && (
|
|
||||||
<div className="mt-4 p-3 bg-blue-50 rounded-lg">
|
|
||||||
<h5 className="text-sm font-medium text-blue-800 mb-1">
|
|
||||||
Special Requirements
|
|
||||||
</h5>
|
|
||||||
<p className="text-sm text-blue-700">{plan.special_requirements}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,171 +0,0 @@
|
|||||||
// ================================================================
|
|
||||||
// frontend/src/components/procurement/ProcurementSummary.tsx
|
|
||||||
// ================================================================
|
|
||||||
/**
|
|
||||||
* Procurement Summary Component
|
|
||||||
* Displays key metrics and summary information
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React from 'react';
|
|
||||||
import type { ProcurementSummary } from '@/api/types/procurement';
|
|
||||||
|
|
||||||
export interface ProcurementSummaryProps {
|
|
||||||
summary: ProcurementSummary;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ProcurementSummary: React.FC<ProcurementSummaryProps> = ({
|
|
||||||
summary,
|
|
||||||
}) => {
|
|
||||||
const formatCurrency = (amount: number) => {
|
|
||||||
return new Intl.NumberFormat('es-ES', {
|
|
||||||
style: 'currency',
|
|
||||||
currency: 'EUR'
|
|
||||||
}).format(amount);
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatPercentage = (value: number | undefined) => {
|
|
||||||
if (value === undefined || value === null) return 'N/A';
|
|
||||||
return `${value.toFixed(1)}%`;
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
{/* Plan Metrics */}
|
|
||||||
<div>
|
|
||||||
<h4 className="text-sm font-medium text-gray-700 mb-3">Plan Overview</h4>
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div className="text-center">
|
|
||||||
<div className="text-xl font-bold text-blue-600">
|
|
||||||
{summary.total_plans}
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-gray-500">Total Plans</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="text-center">
|
|
||||||
<div className="text-xl font-bold text-green-600">
|
|
||||||
{summary.active_plans}
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-gray-500">Active Plans</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Requirements Metrics */}
|
|
||||||
<div>
|
|
||||||
<h4 className="text-sm font-medium text-gray-700 mb-3">Requirements</h4>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span className="text-sm text-gray-600">Total</span>
|
|
||||||
<span className="text-sm font-medium">{summary.total_requirements}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span className="text-sm text-gray-600">Pending</span>
|
|
||||||
<span className="text-sm font-medium text-yellow-600">
|
|
||||||
{summary.pending_requirements}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span className="text-sm text-gray-600">Critical</span>
|
|
||||||
<span className="text-sm font-medium text-red-600">
|
|
||||||
{summary.critical_requirements}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Cost Metrics */}
|
|
||||||
<div>
|
|
||||||
<h4 className="text-sm font-medium text-gray-700 mb-3">Financial</h4>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span className="text-sm text-gray-600">Estimated</span>
|
|
||||||
<span className="text-sm font-medium text-blue-600">
|
|
||||||
{formatCurrency(summary.total_estimated_cost)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span className="text-sm text-gray-600">Approved</span>
|
|
||||||
<span className="text-sm font-medium text-green-600">
|
|
||||||
{formatCurrency(summary.total_approved_cost)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span className="text-sm text-gray-600">Variance</span>
|
|
||||||
<span className={`text-sm font-medium ${
|
|
||||||
summary.cost_variance >= 0 ? 'text-green-600' : 'text-red-600'
|
|
||||||
}`}>
|
|
||||||
{summary.cost_variance >= 0 ? '+' : ''}
|
|
||||||
{formatCurrency(summary.cost_variance)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Performance Metrics */}
|
|
||||||
{(summary.average_fulfillment_rate || summary.average_on_time_delivery) && (
|
|
||||||
<div>
|
|
||||||
<h4 className="text-sm font-medium text-gray-700 mb-3">Performance</h4>
|
|
||||||
<div className="space-y-2">
|
|
||||||
{summary.average_fulfillment_rate && (
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span className="text-sm text-gray-600">Fulfillment Rate</span>
|
|
||||||
<span className="text-sm font-medium">
|
|
||||||
{formatPercentage(summary.average_fulfillment_rate)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{summary.average_on_time_delivery && (
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span className="text-sm text-gray-600">On-Time Delivery</span>
|
|
||||||
<span className="text-sm font-medium">
|
|
||||||
{formatPercentage(summary.average_on_time_delivery)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Top Suppliers */}
|
|
||||||
{summary.top_suppliers.length > 0 && (
|
|
||||||
<div>
|
|
||||||
<h4 className="text-sm font-medium text-gray-700 mb-3">Top Suppliers</h4>
|
|
||||||
<div className="space-y-1">
|
|
||||||
{summary.top_suppliers.slice(0, 3).map((supplier, index) => (
|
|
||||||
<div key={index} className="flex justify-between text-sm">
|
|
||||||
<span className="truncate">{supplier.name}</span>
|
|
||||||
<span className="text-gray-500">
|
|
||||||
{supplier.count || 0} orders
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Critical Items */}
|
|
||||||
{summary.critical_items.length > 0 && (
|
|
||||||
<div>
|
|
||||||
<h4 className="text-sm font-medium text-gray-700 mb-3 text-red-600">
|
|
||||||
Critical Items
|
|
||||||
</h4>
|
|
||||||
<div className="space-y-1">
|
|
||||||
{summary.critical_items.slice(0, 3).map((item, index) => (
|
|
||||||
<div key={index} className="flex justify-between text-sm">
|
|
||||||
<span className="truncate">{item.name}</span>
|
|
||||||
<span className="text-red-500">
|
|
||||||
{item.stock || 0} left
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
// ================================================================
|
|
||||||
// frontend/src/components/procurement/index.ts
|
|
||||||
// ================================================================
|
|
||||||
/**
|
|
||||||
* Procurement Components Export
|
|
||||||
* Main export point for procurement planning components
|
|
||||||
*/
|
|
||||||
|
|
||||||
export { ProcurementDashboard } from './ProcurementDashboard';
|
|
||||||
export { ProcurementPlanCard } from './ProcurementPlanCard';
|
|
||||||
export { ProcurementSummary } from './ProcurementSummary';
|
|
||||||
export { CriticalRequirements } from './CriticalRequirements';
|
|
||||||
export { GeneratePlanModal } from './GeneratePlanModal';
|
|
||||||
|
|
||||||
export type {
|
|
||||||
ProcurementDashboardProps,
|
|
||||||
ProcurementPlanCardProps
|
|
||||||
} from './ProcurementDashboard';
|
|
||||||
@@ -1,323 +0,0 @@
|
|||||||
// frontend/src/components/recipes/IngredientList.tsx
|
|
||||||
import React from 'react';
|
|
||||||
import {
|
|
||||||
Plus,
|
|
||||||
Minus,
|
|
||||||
Edit2,
|
|
||||||
Trash2,
|
|
||||||
GripVertical,
|
|
||||||
Info,
|
|
||||||
AlertCircle,
|
|
||||||
Package,
|
|
||||||
Droplets,
|
|
||||||
Scale,
|
|
||||||
Euro
|
|
||||||
} from 'lucide-react';
|
|
||||||
|
|
||||||
import { RecipeIngredient } from '../../api/services/recipes.service';
|
|
||||||
|
|
||||||
interface IngredientListProps {
|
|
||||||
ingredients: RecipeIngredient[];
|
|
||||||
editable?: boolean;
|
|
||||||
showCosts?: boolean;
|
|
||||||
showGroups?: boolean;
|
|
||||||
batchMultiplier?: number;
|
|
||||||
onAddIngredient?: () => void;
|
|
||||||
onEditIngredient?: (ingredient: RecipeIngredient) => void;
|
|
||||||
onRemoveIngredient?: (ingredientId: string) => void;
|
|
||||||
onReorderIngredients?: (ingredients: RecipeIngredient[]) => void;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const IngredientList: React.FC<IngredientListProps> = ({
|
|
||||||
ingredients,
|
|
||||||
editable = false,
|
|
||||||
showCosts = false,
|
|
||||||
showGroups = true,
|
|
||||||
batchMultiplier = 1,
|
|
||||||
onAddIngredient,
|
|
||||||
onEditIngredient,
|
|
||||||
onRemoveIngredient,
|
|
||||||
onReorderIngredients,
|
|
||||||
className = ''
|
|
||||||
}) => {
|
|
||||||
// Group ingredients by ingredient_group
|
|
||||||
const groupedIngredients = React.useMemo(() => {
|
|
||||||
if (!showGroups) {
|
|
||||||
return { 'All Ingredients': ingredients };
|
|
||||||
}
|
|
||||||
|
|
||||||
const groups: Record<string, RecipeIngredient[]> = {};
|
|
||||||
|
|
||||||
ingredients.forEach(ingredient => {
|
|
||||||
const group = ingredient.ingredient_group || 'Other';
|
|
||||||
if (!groups[group]) {
|
|
||||||
groups[group] = [];
|
|
||||||
}
|
|
||||||
groups[group].push(ingredient);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Sort ingredients within each group by order
|
|
||||||
Object.keys(groups).forEach(group => {
|
|
||||||
groups[group].sort((a, b) => a.ingredient_order - b.ingredient_order);
|
|
||||||
});
|
|
||||||
|
|
||||||
return groups;
|
|
||||||
}, [ingredients, showGroups]);
|
|
||||||
|
|
||||||
// Get unit icon
|
|
||||||
const getUnitIcon = (unit: string) => {
|
|
||||||
switch (unit.toLowerCase()) {
|
|
||||||
case 'g':
|
|
||||||
case 'kg':
|
|
||||||
return <Scale className="w-4 h-4" />;
|
|
||||||
case 'ml':
|
|
||||||
case 'l':
|
|
||||||
return <Droplets className="w-4 h-4" />;
|
|
||||||
case 'units':
|
|
||||||
case 'pieces':
|
|
||||||
case 'pcs':
|
|
||||||
return <Package className="w-4 h-4" />;
|
|
||||||
default:
|
|
||||||
return <Scale className="w-4 h-4" />;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Format quantity with multiplier
|
|
||||||
const formatQuantity = (quantity: number, unit: string) => {
|
|
||||||
const adjustedQuantity = quantity * batchMultiplier;
|
|
||||||
return `${adjustedQuantity} ${unit}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Calculate total cost
|
|
||||||
const getTotalCost = () => {
|
|
||||||
return ingredients.reduce((total, ingredient) => {
|
|
||||||
const cost = ingredient.total_cost || 0;
|
|
||||||
return total + (cost * batchMultiplier);
|
|
||||||
}, 0);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={`bg-white rounded-lg border ${className}`}>
|
|
||||||
{/* Header */}
|
|
||||||
<div className="p-4 border-b bg-gray-50 rounded-t-lg">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<h3 className="text-lg font-semibold text-gray-900">Ingredients</h3>
|
|
||||||
<p className="text-sm text-gray-600">
|
|
||||||
{ingredients.length} ingredient{ingredients.length !== 1 ? 's' : ''}
|
|
||||||
{batchMultiplier !== 1 && (
|
|
||||||
<span className="ml-2 px-2 py-1 bg-blue-100 text-blue-800 rounded text-xs">
|
|
||||||
×{batchMultiplier} batch
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
{showCosts && (
|
|
||||||
<div className="text-right">
|
|
||||||
<div className="text-sm text-gray-600">Total Cost</div>
|
|
||||||
<div className="text-lg font-semibold text-gray-900 flex items-center">
|
|
||||||
<Euro className="w-4 h-4 mr-1" />
|
|
||||||
{getTotalCost().toFixed(2)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{editable && onAddIngredient && (
|
|
||||||
<button
|
|
||||||
onClick={onAddIngredient}
|
|
||||||
className="px-3 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors flex items-center space-x-2"
|
|
||||||
>
|
|
||||||
<Plus className="w-4 h-4" />
|
|
||||||
<span>Add Ingredient</span>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Ingredients List */}
|
|
||||||
<div className="divide-y">
|
|
||||||
{Object.entries(groupedIngredients).map(([groupName, groupIngredients]) => (
|
|
||||||
<div key={groupName}>
|
|
||||||
{/* Group Header */}
|
|
||||||
{showGroups && Object.keys(groupedIngredients).length > 1 && (
|
|
||||||
<div className="px-4 py-2 bg-gray-25 border-b">
|
|
||||||
<h4 className="text-sm font-medium text-gray-700 uppercase tracking-wide">
|
|
||||||
{groupName}
|
|
||||||
</h4>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Group Ingredients */}
|
|
||||||
{groupIngredients.map((ingredient, index) => (
|
|
||||||
<div
|
|
||||||
key={ingredient.id}
|
|
||||||
className="p-4 hover:bg-gray-50 transition-colors"
|
|
||||||
>
|
|
||||||
<div className="flex items-center space-x-4">
|
|
||||||
{/* Drag Handle */}
|
|
||||||
{editable && onReorderIngredients && (
|
|
||||||
<div className="cursor-move text-gray-400 hover:text-gray-600">
|
|
||||||
<GripVertical className="w-4 h-4" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Order Number */}
|
|
||||||
<div className="w-6 h-6 bg-gray-200 rounded-full flex items-center justify-center text-xs font-medium text-gray-600">
|
|
||||||
{ingredient.ingredient_order}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Ingredient Info */}
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<div className="flex items-center space-x-2 mb-1">
|
|
||||||
<h4 className="font-medium text-gray-900">
|
|
||||||
{ingredient.ingredient_id} {/* This would be ingredient name from inventory */}
|
|
||||||
</h4>
|
|
||||||
|
|
||||||
{ingredient.is_optional && (
|
|
||||||
<span className="px-2 py-1 bg-yellow-100 text-yellow-800 rounded text-xs">
|
|
||||||
Optional
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Quantity */}
|
|
||||||
<div className="flex items-center space-x-2 text-sm text-gray-600">
|
|
||||||
{getUnitIcon(ingredient.unit)}
|
|
||||||
<span className="font-medium">
|
|
||||||
{formatQuantity(ingredient.quantity, ingredient.unit)}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
{ingredient.alternative_quantity && ingredient.alternative_unit && (
|
|
||||||
<span className="text-gray-500">
|
|
||||||
(≈ {formatQuantity(ingredient.alternative_quantity, ingredient.alternative_unit)})
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Preparation Method */}
|
|
||||||
{ingredient.preparation_method && (
|
|
||||||
<div className="text-sm text-gray-600 mt-1">
|
|
||||||
<span className="font-medium">Prep:</span> {ingredient.preparation_method}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Notes */}
|
|
||||||
{ingredient.ingredient_notes && (
|
|
||||||
<div className="text-sm text-gray-600 mt-1 flex items-start">
|
|
||||||
<Info className="w-3 h-3 mr-1 mt-0.5 flex-shrink-0" />
|
|
||||||
<span>{ingredient.ingredient_notes}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Substitutions */}
|
|
||||||
{ingredient.substitution_options && (
|
|
||||||
<div className="text-sm text-blue-600 mt-1">
|
|
||||||
<span className="font-medium">Substitutions available</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Cost */}
|
|
||||||
{showCosts && (
|
|
||||||
<div className="text-right">
|
|
||||||
<div className="text-sm font-medium text-gray-900">
|
|
||||||
€{((ingredient.total_cost || 0) * batchMultiplier).toFixed(2)}
|
|
||||||
</div>
|
|
||||||
{ingredient.unit_cost && (
|
|
||||||
<div className="text-xs text-gray-600">
|
|
||||||
€{ingredient.unit_cost.toFixed(2)}/{ingredient.unit}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{ingredient.cost_updated_at && (
|
|
||||||
<div className="text-xs text-gray-500">
|
|
||||||
{new Date(ingredient.cost_updated_at).toLocaleDateString()}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Actions */}
|
|
||||||
{editable && (
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<button
|
|
||||||
onClick={() => onEditIngredient?.(ingredient)}
|
|
||||||
className="p-1 text-gray-400 hover:text-blue-600 transition-colors"
|
|
||||||
title="Edit ingredient"
|
|
||||||
>
|
|
||||||
<Edit2 className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={() => onRemoveIngredient?.(ingredient.id)}
|
|
||||||
className="p-1 text-gray-400 hover:text-red-600 transition-colors"
|
|
||||||
title="Remove ingredient"
|
|
||||||
>
|
|
||||||
<Trash2 className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{/* Empty State */}
|
|
||||||
{ingredients.length === 0 && (
|
|
||||||
<div className="p-8 text-center">
|
|
||||||
<Package className="w-12 h-12 text-gray-400 mx-auto mb-4" />
|
|
||||||
<h3 className="text-lg font-medium text-gray-900 mb-2">No ingredients yet</h3>
|
|
||||||
<p className="text-gray-600 mb-4">
|
|
||||||
Add ingredients to start building your recipe
|
|
||||||
</p>
|
|
||||||
{editable && onAddIngredient && (
|
|
||||||
<button
|
|
||||||
onClick={onAddIngredient}
|
|
||||||
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
|
||||||
>
|
|
||||||
Add First Ingredient
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Summary */}
|
|
||||||
{ingredients.length > 0 && (
|
|
||||||
<div className="p-4 bg-gray-50 border-t rounded-b-lg">
|
|
||||||
<div className="flex items-center justify-between text-sm">
|
|
||||||
<div className="flex items-center space-x-4">
|
|
||||||
<span className="text-gray-600">
|
|
||||||
{ingredients.length} total ingredients
|
|
||||||
</span>
|
|
||||||
|
|
||||||
{ingredients.filter(i => i.is_optional).length > 0 && (
|
|
||||||
<span className="text-yellow-600">
|
|
||||||
{ingredients.filter(i => i.is_optional).length} optional
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{ingredients.some(i => i.substitution_options) && (
|
|
||||||
<span className="text-blue-600">
|
|
||||||
{ingredients.filter(i => i.substitution_options).length} with substitutions
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{showCosts && (
|
|
||||||
<div className="font-medium text-gray-900">
|
|
||||||
Total: €{getTotalCost().toFixed(2)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default IngredientList;
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user