Initial commit - production deployment

This commit is contained in:
2026-01-21 17:17:16 +01:00
commit c23d00dd92
2289 changed files with 638440 additions and 0 deletions

41
frontend/.dockerignore Normal file
View File

@@ -0,0 +1,41 @@
# Dependencies
node_modules
# Production build output (should be recreated during Docker build)
dist
# Testing
coverage
test-results
playwright-report
# Logs
logs
*.log
# Environment variables
.env*
!.env.example
# IDE files
.vscode
.idea
*.swp
*.swo
# OS generated files
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db
# Build outputs
build
.nyc_output
# Local configuration files
.git
.gitignore

25
frontend/.eslintrc.json Normal file
View File

@@ -0,0 +1,25 @@
{
"env": {
"browser": true,
"es2020": true
},
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"plugin:react-hooks/recommended"
],
"parser": "@typescript-eslint/parser",
"parserOptions": {
"ecmaVersion": "latest",
"sourceType": "module"
},
"plugins": ["react-refresh"],
"rules": {
"react-refresh/only-export-components": [
"warn",
{ "allowConstantExport": true }
],
"@typescript-eslint/no-explicit-any": "error",
"@typescript-eslint/no-unused-vars": ["error", { "argsIgnorePattern": "^_" }]
}
}

41
frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,41 @@
# Dependencies
node_modules
.pnp
.pnp.js
# Testing
coverage
# Production
dist
dist-ssr
# Local env files
.env.local
.env.development.local
.env.test.local
.env.production.local
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
# Storybook
storybook-static

9
frontend/.prettierrc Normal file
View File

@@ -0,0 +1,9 @@
{
"semi": true,
"trailingComma": "es5",
"singleQuote": true,
"printWidth": 100,
"tabWidth": 2,
"useTabs": false,
"plugins": ["prettier-plugin-tailwindcss"]
}

View File

@@ -0,0 +1,85 @@
# Kubernetes-optimized Dockerfile for Frontend
# Multi-stage build for production deployment
# Stage 1: Build the application
FROM node:18-alpine AS builder
WORKDIR /app
# Copy package files
COPY package*.json ./
# Install all dependencies for building
RUN npm ci --verbose && \
npm cache clean --force
# Copy source code (excluding unnecessary files like node_modules, dist, etc.)
COPY . .
# Create a default runtime config in the public directory if it doesn't exist to satisfy the reference in index.html
RUN if [ ! -f public/runtime-config.js ]; then \
mkdir -p public && \
echo "window.__RUNTIME_CONFIG__ = {};" > public/runtime-config.js; \
fi
# Set build-time environment variables to prevent hanging on undefined variables
ENV NODE_ENV=production
ENV CI=true
ENV VITE_API_URL=/api
ENV VITE_APP_TITLE="BakeWise"
ENV VITE_APP_VERSION="1.0.0"
ENV VITE_PILOT_MODE_ENABLED="false"
ENV VITE_PILOT_COUPON_CODE="PILOT2025"
ENV VITE_PILOT_TRIAL_MONTHS="3"
ENV VITE_STRIPE_PUBLISHABLE_KEY="pk_test_"
# Set Node.js memory limit for the build process
ENV NODE_OPTIONS="--max-old-space-size=4096"
RUN npm run build
# Stage 2: Production server with Nginx
FROM nginx:1.25-alpine AS production
# Install curl for health checks
RUN apk add --no-cache curl
# Copy main nginx configuration that sets the PID file location
COPY nginx-main.conf /etc/nginx/nginx.conf
# Remove default nginx configuration
RUN rm /etc/nginx/conf.d/default.conf
# Copy custom nginx configuration
COPY nginx.conf /etc/nginx/conf.d/
# Copy built application from builder stage
COPY --from=builder /app/dist /usr/share/nginx/html
# Copy and setup environment substitution script
COPY substitute-env.sh /docker-entrypoint.d/30-substitute-env.sh
# Make the script executable
RUN chmod +x /docker-entrypoint.d/30-substitute-env.sh
# Set proper permissions
RUN chown -R nginx:nginx /usr/share/nginx/html && \
chown -R nginx:nginx /var/cache/nginx && \
chown -R nginx:nginx /var/log/nginx && \
chown -R nginx:nginx /etc/nginx/conf.d
# Create nginx PID directory and fix permissions
RUN mkdir -p /var/run/nginx /var/lib/nginx/tmp && \
chown -R nginx:nginx /var/run/nginx /var/lib/nginx /etc/nginx
# Switch to non-root user
USER nginx
# Expose port 3000 (to match current setup)
EXPOSE 3000
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \
CMD curl -f http://localhost:3000/health || exit 1
# Start nginx
CMD ["nginx", "-g", "daemon off;"]

View File

@@ -0,0 +1,89 @@
# Kubernetes-optimized DEBUG Dockerfile for Frontend
# Multi-stage build for DEVELOPMENT/DEBUG deployment
# This build DISABLES minification and provides full React error messages
# Stage 1: Build the application in DEVELOPMENT MODE
FROM node:18-alpine AS builder
WORKDIR /app
# Copy package files
COPY package*.json ./
# Install all dependencies for building
RUN npm ci --verbose && \
npm cache clean --force
# Copy source code (excluding unnecessary files like node_modules, dist, etc.)
COPY . .
# Create a default runtime config in the public directory if it doesn't exist to satisfy the reference in index.html
RUN if [ ! -f public/runtime-config.js ]; then \
mkdir -p public && \
echo "window.__RUNTIME_CONFIG__ = {};" > public/runtime-config.js; \
fi
# DEBUG BUILD SETTINGS - NO MINIFICATION
# This will produce larger bundles but with full error messages
ENV NODE_ENV=development
ENV CI=true
ENV VITE_API_URL=/api
ENV VITE_APP_TITLE="BakeWise (Debug)"
ENV VITE_APP_VERSION="1.0.0-debug"
ENV VITE_PILOT_MODE_ENABLED="false"
ENV VITE_PILOT_COUPON_CODE="PILOT2025"
ENV VITE_PILOT_TRIAL_MONTHS="3"
ENV VITE_STRIPE_PUBLISHABLE_KEY="pk_test_"
# Set Node.js memory limit for the build process
ENV NODE_OPTIONS="--max-old-space-size=4096"
# Build in development mode (no minification, full source maps)
RUN npm run build -- --mode development
# Stage 2: Production server with Nginx (same as production)
FROM nginx:1.25-alpine AS production
# Install curl for health checks
RUN apk add --no-cache curl
# Copy main nginx configuration that sets the PID file location
COPY nginx-main.conf /etc/nginx/nginx.conf
# Remove default nginx configuration
RUN rm /etc/nginx/conf.d/default.conf
# Copy custom nginx configuration
COPY nginx.conf /etc/nginx/conf.d/
# Copy built application from builder stage
COPY --from=builder /app/dist /usr/share/nginx/html
# Copy and setup environment substitution script
COPY substitute-env.sh /docker-entrypoint.d/30-substitute-env.sh
# Make the script executable
RUN chmod +x /docker-entrypoint.d/30-substitute-env.sh
# Set proper permissions
RUN chown -R nginx:nginx /usr/share/nginx/html && \
chown -R nginx:nginx /var/cache/nginx && \
chown -R nginx:nginx /var/log/nginx && \
chown -R nginx:nginx /etc/nginx/conf.d
# Create nginx PID directory and fix permissions
RUN mkdir -p /var/run/nginx /var/lib/nginx/tmp && \
chown -R nginx:nginx /var/run/nginx /var/lib/nginx /etc/nginx
# Switch to non-root user
USER nginx
# Expose port 3000 (to match current setup)
EXPOSE 3000
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \
CMD curl -f http://localhost:3000/health || exit 1
# Start nginx
CMD ["nginx", "-g", "daemon off;"]

141
frontend/E2E_TESTING.md Normal file
View File

@@ -0,0 +1,141 @@
# E2E Testing with Playwright - Quick Start
## ⚡ Quick Start
### Run E2E Tests
```bash
cd frontend
npm run test:e2e
```
### Open Interactive UI
```bash
npm run test:e2e:ui
```
### Generate Tests by Recording
```bash
npm run test:e2e:codegen
```
## 📖 Full Documentation
See [tests/README.md](./tests/README.md) for complete documentation.
## 🎯 What's Tested
-**Authentication**: Login, registration, logout flows
-**Onboarding**: Multi-step wizard with file upload
-**Dashboard**: Health status, action queue, purchase orders
-**Operations**: Product/recipe creation, inventory management
- 🔜 **Analytics**: Forecasting, sales analytics
- 🔜 **Settings**: Profile, team, subscription management
## 🔧 Available Commands
| Command | Description |
|---------|-------------|
| `npm run test:e2e` | Run all tests (headless) |
| `npm run test:e2e:ui` | Run with interactive UI |
| `npm run test:e2e:headed` | Run with visible browser |
| `npm run test:e2e:debug` | Debug tests step-by-step |
| `npm run test:e2e:report` | View test results report |
| `npm run test:e2e:codegen` | Record new tests |
## 🚀 CI/CD
Tests run automatically on GitHub Actions:
- Every push to `main` or `develop`
- Every pull request
- Results posted as PR comments
- Artifacts (screenshots, videos) uploaded on failure
## 🔐 Test Credentials
Set environment variables:
```bash
export TEST_USER_EMAIL="test@bakery.com"
export TEST_USER_PASSWORD="your-test-password"
```
Or create `.env` file in frontend directory:
```
TEST_USER_EMAIL=test@bakery.com
TEST_USER_PASSWORD=your-test-password
```
## 📂 Project Structure
```
frontend/tests/
├── auth/ # Login, register tests
├── onboarding/ # Wizard flow tests
├── dashboard/ # Dashboard tests
├── operations/ # Business logic tests
├── fixtures/ # Test data (CSV, etc.)
└── helpers/ # Reusable utilities
```
## 🎓 Writing Your First Test
```typescript
import { test, expect } from '@playwright/test';
test.describe('My Feature', () => {
test.use({ storageState: 'tests/.auth/user.json' }); // Use auth
test('should work correctly', async ({ page }) => {
await page.goto('/app/my-page');
await page.getByRole('button', { name: 'Click me' }).click();
await expect(page).toHaveURL('/success');
});
});
```
## 🐛 Debugging
```bash
# Step through test with inspector
npm run test:e2e:debug
# Run specific test
npx playwright test tests/auth/login.spec.ts
# Run with browser visible
npm run test:e2e:headed
```
## 📊 View Reports
```bash
npm run test:e2e:report
```
Opens HTML report with:
- Test results
- Screenshots on failure
- Videos on failure
- Execution traces
## 🌍 Multi-Browser Testing
Tests run on:
- ✅ Chromium (Chrome/Edge)
- ✅ Firefox
- ✅ WebKit (Safari)
- ✅ Mobile Chrome (Pixel 5)
- ✅ Mobile Safari (iPhone 13)
## 💰 Cost: $0
Playwright is 100% free and open source. No cloud subscription required.
## 📚 Learn More
- [Full Documentation](./tests/README.md)
- [Playwright Docs](https://playwright.dev)
- [Best Practices](https://playwright.dev/docs/best-practices)
---
**Need help?** Check [tests/README.md](./tests/README.md) or [Playwright Docs](https://playwright.dev)

View File

@@ -0,0 +1,333 @@
# ✅ Playwright E2E Testing Setup Complete!
## 🎉 What's Been Implemented
Your Bakery-IA application now has a complete, production-ready E2E testing infrastructure using Playwright.
### 📦 Installation
- ✅ Playwright installed (`@playwright/test@1.56.1`)
- ✅ Browsers installed (Chromium, Firefox, WebKit)
- ✅ All dependencies configured
### ⚙️ Configuration
-`playwright.config.ts` - Configured for Vite/React
- ✅ Auto-starts dev server before tests
- ✅ Multi-browser testing enabled
- ✅ Mobile viewport testing configured
- ✅ Screenshots/videos on failure
### 🗂️ Test Structure Created
```
frontend/tests/
├── auth/
│ ├── login.spec.ts ✅ Login flow tests
│ ├── register.spec.ts ✅ Registration tests
│ └── logout.spec.ts ✅ Logout tests
├── onboarding/
│ ├── wizard-navigation.spec.ts ✅ Wizard flow tests
│ └── file-upload.spec.ts ✅ File upload tests
├── dashboard/
│ ├── dashboard-smoke.spec.ts ✅ Dashboard tests
│ └── purchase-order.spec.ts ✅ PO management tests
├── operations/
│ └── add-product.spec.ts ✅ Product creation tests
├── fixtures/
│ ├── sample-inventory.csv ✅ Test data
│ └── invalid-file.txt ✅ Validation data
├── helpers/
│ ├── auth.ts ✅ Auth utilities
│ └── utils.ts ✅ Common utilities
├── .auth/ ✅ Saved auth states
├── auth.setup.ts ✅ Global auth setup
├── EXAMPLE_TEST.spec.ts ✅ Template for new tests
└── README.md ✅ Complete documentation
```
### 📝 Test Coverage Implemented
#### Authentication (3 test files)
- ✅ Login with valid credentials
- ✅ Login with invalid credentials
- ✅ Validation errors (empty fields)
- ✅ Password visibility toggle
- ✅ Registration flow
- ✅ Registration validation
- ✅ Logout functionality
- ✅ Protected routes after logout
#### Onboarding (2 test files)
- ✅ Wizard step navigation
- ✅ Forward/backward navigation
- ✅ Progress indicators
- ✅ File upload component
- ✅ Drag & drop upload
- ✅ File type validation
- ✅ File removal
#### Dashboard (2 test files)
- ✅ Dashboard smoke tests
- ✅ Key sections display
- ✅ Unified Add button
- ✅ Navigation links
- ✅ Purchase order approval
- ✅ Purchase order rejection
- ✅ PO details view
- ✅ Mobile responsiveness
#### Operations (1 test file)
- ✅ Add new product/recipe
- ✅ Form validation
- ✅ Add ingredients
- ✅ Image upload
- ✅ Cancel creation
**Total: 8 test files with 30+ test cases**
### 🚀 NPM Scripts Added
```json
"test:e2e": "playwright test" // Run all tests
"test:e2e:ui": "playwright test --ui" // Interactive UI
"test:e2e:headed": "playwright test --headed" // Visible browser
"test:e2e:debug": "playwright test --debug" // Step-through debug
"test:e2e:report": "playwright show-report" // View results
"test:e2e:codegen": "playwright codegen ..." // Record tests
```
### 🔄 CI/CD Integration
#### GitHub Actions Workflow Created
-`.github/workflows/playwright.yml`
- ✅ Runs on push to `main`/`develop`
- ✅ Runs on pull requests
- ✅ Uploads test reports as artifacts
- ✅ Uploads videos/screenshots on failure
- ✅ Comments on PRs with results
#### Required GitHub Secrets
Set these in repository settings:
- `TEST_USER_EMAIL`: Test user email
- `TEST_USER_PASSWORD`: Test user password
### 📚 Documentation Created
1. **[tests/README.md](./tests/README.md)** - Complete guide
- Getting started
- Running tests
- Writing tests
- Debugging
- Best practices
- Common issues
2. **[E2E_TESTING.md](./E2E_TESTING.md)** - Quick reference
- Quick start commands
- Available scripts
- Test structure
- First test example
3. **[tests/EXAMPLE_TEST.spec.ts](./tests/EXAMPLE_TEST.spec.ts)** - Template
- Complete examples of all test patterns
- Forms, modals, navigation
- API mocking
- File uploads
- Best practices
### 🛠️ Utilities & Helpers
#### Authentication Helpers
```typescript
import { login, logout, TEST_USER } from './helpers/auth';
await login(page, TEST_USER);
await logout(page);
```
#### General Utilities
```typescript
import {
waitForLoadingToFinish,
expectToastMessage,
generateTestId,
mockApiResponse,
uploadFile
} from './helpers/utils';
```
### 🌐 Multi-Browser Support
Tests automatically run on:
- ✅ Chromium (Chrome/Edge)
- ✅ Firefox
- ✅ WebKit (Safari)
- ✅ Mobile Chrome (Pixel 5)
- ✅ Mobile Safari (iPhone 13)
### 💰 Cost
**$0** - Completely free, no subscriptions required!
Savings vs alternatives:
- Cypress Team Plan: **Saves $900/year**
- BrowserStack: **Saves $1,200/year**
---
## 🚀 Next Steps
### 1. Set Up Test User (REQUIRED)
Create a test user in your database or update credentials:
```bash
cd frontend
export TEST_USER_EMAIL="your-test-email@bakery.com"
export TEST_USER_PASSWORD="your-test-password"
```
Or add to `.env` file:
```
TEST_USER_EMAIL=test@bakery.com
TEST_USER_PASSWORD=test-password-123
```
### 2. Run Your First Tests
```bash
cd frontend
# Run all tests
npm run test:e2e
# Or open interactive UI
npm run test:e2e:ui
```
### 3. Set Up GitHub Secrets
In your GitHub repository:
1. Go to Settings → Secrets and variables → Actions
2. Add secrets:
- `TEST_USER_EMAIL`
- `TEST_USER_PASSWORD`
### 4. Generate New Tests
```bash
# Record your interactions to generate test code
npm run test:e2e:codegen
```
### 5. Expand Test Coverage
Add tests for:
- 🔜 Analytics pages
- 🔜 Settings and configuration
- 🔜 Team management
- 🔜 Payment flows (Stripe)
- 🔜 Mobile POS scenarios
- 🔜 Inventory operations
- 🔜 Report generation
Use [tests/EXAMPLE_TEST.spec.ts](./tests/EXAMPLE_TEST.spec.ts) as a template!
---
## 📖 Quick Reference
### Run Tests
```bash
npm run test:e2e # All tests (headless)
npm run test:e2e:ui # Interactive UI
npm run test:e2e:headed # See browser
npm run test:e2e:debug # Step-through debug
```
### View Results
```bash
npm run test:e2e:report # HTML report
```
### Create Tests
```bash
npm run test:e2e:codegen # Record actions
```
### Run Specific Tests
```bash
npx playwright test tests/auth/login.spec.ts
npx playwright test --grep "login"
```
---
## 📚 Documentation Links
- **Main Guide**: [tests/README.md](./tests/README.md)
- **Quick Start**: [E2E_TESTING.md](./E2E_TESTING.md)
- **Example Tests**: [tests/EXAMPLE_TEST.spec.ts](./tests/EXAMPLE_TEST.spec.ts)
- **Playwright Docs**: https://playwright.dev
---
## 🎯 Test Statistics
- **Test Files**: 8
- **Test Cases**: 30+
- **Test Utilities**: 2 helper modules
- **Test Fixtures**: 2 data files
- **Browsers**: 5 configurations
- **Documentation**: 3 comprehensive guides
---
## ✨ Benefits You Now Have
**Confidence** - Catch bugs before users do
**Speed** - Automated testing saves hours of manual testing
**Quality** - Consistent testing across browsers
**CI/CD** - Automatic testing on every commit
**Documentation** - Self-documenting user flows
**Regression Prevention** - Tests prevent old bugs from returning
**Team Collaboration** - Non-technical team members can record tests
**Cost Savings** - $0 vs $900-2,700/year for alternatives
---
## 🎓 Learning Resources
1. **Start Here**: [tests/README.md](./tests/README.md)
2. **Learn by Example**: [tests/EXAMPLE_TEST.spec.ts](./tests/EXAMPLE_TEST.spec.ts)
3. **Official Docs**: https://playwright.dev/docs/intro
4. **Best Practices**: https://playwright.dev/docs/best-practices
5. **VS Code Extension**: Install "Playwright Test for VSCode"
---
## 🆘 Need Help?
1. Check [tests/README.md](./tests/README.md) - Common issues section
2. Run with debug mode: `npm run test:e2e:debug`
3. View trace files when tests fail
4. Check Playwright docs: https://playwright.dev
5. Use test inspector: `npx playwright test --debug`
---
## 🎉 You're All Set!
Your E2E testing infrastructure is production-ready. Start by running:
```bash
cd frontend
npm run test:e2e:ui
```
Then expand coverage by adding tests for your specific business flows.
**Happy Testing!** 🎭
---
*Generated: 2025-01-14*
*Framework: Playwright 1.56.1*
*Coverage: Authentication, Onboarding, Dashboard, Operations*

763
frontend/README.md Normal file
View File

@@ -0,0 +1,763 @@
# Frontend Dashboard
## Overview
The **Bakery-IA Frontend Dashboard** is a modern, responsive React-based web application that provides bakery owners and operators with comprehensive real-time visibility into their operations. Built with TypeScript and cutting-edge React ecosystem tools, it delivers an intuitive interface for demand forecasting, inventory management, production planning, and operational analytics.
## Key Features
### AI-Powered Demand Forecasting
- **Visual Forecast Charts** - Interactive Chart.js visualizations of demand predictions
- **Multi-Day Forecasts** - View predictions up to 30 days ahead
- **Confidence Intervals** - Visual representation of prediction uncertainty
- **Historical Comparison** - Compare forecasts with actual sales
- **Forecast Accuracy Metrics** - Track model performance over time
- **Weather Integration** - See how weather impacts demand
- **One-Click Forecast Generation** - Generate forecasts for all products instantly
### Real-Time Operational Dashboard
- **Live KPI Cards** - Real-time metrics for sales, inventory, production
- **Alert Stream (SSE)** - Instant notifications for critical events
- **AI Impact Showcase** - Celebrate AI wins with handling rate and savings metrics
- **Prevented Issues Card** - Highlights problems AI automatically resolved
- **Production Status** - Live view of current production batches
- **Inventory Levels** - Color-coded stock levels with expiry warnings
- **Order Pipeline** - Track customer orders from placement to fulfillment
### Enriched Alert System (NEW)
- **Multi-Dimensional Priority Scoring** - Intelligent 0-100 priority scores with 4 weighted factors
- Business Impact (40%): Financial consequences, affected orders
- Urgency (30%): Time sensitivity, deadlines
- User Agency (20%): Can you take action?
- AI Confidence (10%): Prediction certainty
- **Smart Alert Classification** - 5 alert types for clear user intent
- 🔴 ACTION_NEEDED: Requires your decision or action
- 🎉 PREVENTED_ISSUE: AI already handled (celebration!)
- 📊 TREND_WARNING: Pattern detected, early warning
- ⏱️ ESCALATION: Auto-action pending, can cancel
- INFORMATION: FYI only, no action needed
- **3-Tab Alert Hub** - Organized navigation (All Alerts / For Me / Archived)
- **Auto-Action Countdown** - Real-time timer for escalation alerts with one-click cancel
- **Priority Score Explainer** - Educational modal showing exact scoring formula
- **Trend Visualizations** - Inline sparklines and directional indicators for trend warnings
- **Action Consequence Previews** - See outcomes before taking action (financial impact, affected systems, reversibility)
- **Response Time Gamification** - Track your alert response performance by priority level with benchmarks
- **Email Digests** - Daily/weekly summaries with celebration-first messaging
- **Full Internationalization** - Complete translations (English, Spanish, Basque)
### Panel de Control (Dashboard Redesign - NEW)
A comprehensive dashboard redesign focused on Jobs-To-Be-Done principles, progressive disclosure, and mobile-first UX.
#### **New Dashboard Components**
- **GlanceableHealthHero** - Traffic light status system (🟢🟡🔴)
- Understand bakery health in 3 seconds (5 AM test)
- Collapsible checklist with progressive disclosure
- Shows urgent action count prominently
- Real-time SSE integration for critical alerts
- Mobile-optimized with large touch targets (44x44px minimum)
- **SetupWizardBlocker** - Full-page setup wizard
- Blocks dashboard access when <50% setup complete
- Step-by-step wizard interface with numbered steps
- Progress bar (0-100%) with completion indicators
- Clear CTAs for each configuration section
- Ensures critical data exists before AI can function
- **CollapsibleSetupBanner** - Compact reminder banner
- Appears when 50-99% setup complete
- Collapsible (default: collapsed) to minimize distraction
- Dismissible for 7 days via localStorage
- Shows remaining sections with item counts
- Links directly to incomplete sections
- **UnifiedActionQueueCard** - Consolidated action queue
- Time-based grouping (Urgent / Today / This Week)
- Smart actions with embedded delivery workflows
- Escalation badges show pending duration
- StockReceiptModal integration for delivery actions
- Real-time updates via SSE
- **ExecutionProgressTracker** - Plan vs actual tracking
- Visual progress bars for production, deliveries, approvals
- Shows what's on track vs behind schedule
- Business impact highlights (orders at risk)
- **IntelligentSystemSummaryCard** - AI insights dashboard
- Shows what AI has done and why
- Celebration-focused messaging for prevented issues
- Recommendations with confidence scores
#### **Three-State Setup Flow Logic**
```
Progress < 50% → SetupWizardBlocker (BLOCKS dashboard access)
Progress 50-99% → CollapsibleSetupBanner (REMINDS but allows access)
Progress 100% → Hidden (COMPLETE, no reminder)
```
**Setup Progress Calculation**:
- **Inventory**: Minimum 3 ingredients, recommended 10
- **Suppliers**: Minimum 1 supplier, recommended 3
- **Recipes**: Minimum 1 recipe, recommended 3
- **Quality**: Optional, recommended 2 templates
**Rationale**: Critical data (ingredients, suppliers, recipes) must exist for AI to function. Recommended data improves AI but isn't required. Progressive disclosure prevents overwhelming new users while reminding them of missing features.
#### **Design Principles**
- **Glanceable First (5-Second Test)** - User should understand status in 3 seconds at 5 AM on phone
- **Mobile-First / One-Handed** - All critical actions in thumb zone, 44x44px min touch targets
- **Progressive Disclosure** - Show 20% that matters 80% of the time, hide complexity until requested
- **Outcome-Focused** - Show business impact ($€, time saved) not just features
- **Trust-Building** - Always show AI reasoning, escalation tracking, financial impact transparency
#### **StockReceiptModal Integration Pattern**
Cross-component communication using CustomEvents for opening stock receipt modal from dashboard alerts:
```typescript
// Emit from smartActionHandlers.ts
window.dispatchEvent(new CustomEvent('stock-receipt:open', {
detail: {
receipt_id?: string,
po_id: string,
tenant_id: string,
mode: 'create' | 'edit'
}
}));
// Listen in UnifiedActionQueueCard.tsx
useEffect(() => {
const handler = (e: CustomEvent) => {
setStockReceiptData({
isOpen: true,
receipt: e.detail
});
};
window.addEventListener('stock-receipt:open', handler);
return () => window.removeEventListener('stock-receipt:open', handler);
}, []);
```
**Workflow**: Delivery alerts (`DELIVERY_ARRIVING_SOON`, `STOCK_RECEIPT_INCOMPLETE`) trigger modal opening with PO context. User completes stock receipt with lot-level tracking and expiration dates. Confirmation triggers `delivery.received` event, auto-resolving related alerts.
#### **Deleted Components (Cleanup Rationale)**
The dashboard redesign replaced or merged 7 legacy components:
- `HealthStatusCard.tsx` Replaced by **GlanceableHealthHero** (traffic light system)
- `InsightsGrid.tsx` Merged into **IntelligentSystemSummaryCard**
- `ProductionTimelineCard.tsx` Replaced by **ExecutionProgressTracker**
- `ActionQueueCard.tsx` Replaced by **UnifiedActionQueueCard** (time-based grouping)
- `ConfigurationProgressWidget.tsx` Replaced by **SetupWizardBlocker** + **CollapsibleSetupBanner**
- `AlertContextActions.tsx` Merged into Alert Hub
- `OrchestrationSummaryCard.tsx` Merged into system summary
**Net Impact**: Deleted ~1,200 lines of old code, added ~811 lines of new focused components, saved ~390 lines overall while improving UX.
#### **Dashboard Layout Order**
1. **Setup Flow** - Blocker or banner (contextual)
2. **GlanceableHealthHero** - Traffic light status
3. **UnifiedActionQueueCard** - What needs attention
4. **ExecutionProgressTracker** - Plan vs actual
5. **AI Impact Showcase** - Celebration cards for prevented issues
6. **IntelligentSystemSummaryCard** - What AI did and why
7. **Quick Action Links** - Navigation shortcuts
### Inventory Management
- **Stock Overview** - All ingredients with current levels and locations
- **Low Stock Alerts** - Automatic warnings when stock falls below thresholds
- **Expiration Tracking** - Prioritize items by expiration date
- **FIFO Compliance** - First-in-first-out consumption tracking
- **Stock Movements** - Complete audit trail of all inventory changes
- **Barcode Scanning Integration** - Quick stock updates via barcode
### Production Planning
- **Production Schedules** - Daily and weekly production calendars
- **Batch Tracking** - Monitor all active production batches
- **Quality Control** - Digital quality check forms and templates
- **Equipment Management** - Track equipment usage and maintenance
- **Recipe Execution** - Step-by-step recipe guidance for production staff
- **Capacity Planning** - Optimize production capacity utilization
### Procurement & Supplier Management
- **Automated Purchase Orders** - AI-generated procurement recommendations
- **Supplier Portal** - Manage supplier relationships and performance
- **Price Comparisons** - Compare supplier pricing across items
- **Delivery Tracking** - Track inbound shipments
- **Supplier Scorecards** - Rate suppliers on quality, delivery, and price
### Sales & Orders
- **Customer Order Management** - Process and track customer orders
- **Sales Analytics** - Revenue trends, product performance, customer insights
- **POS Integration** - Automatic sales data sync from Square/Toast/Lightspeed
- **Sales History** - Complete historical sales data with filtering and export
### Onboarding Wizard
- **Multi-Step Onboarding** - Guided 15-step setup process for new bakeries
- **POI Detection Step** - Automatic detection of nearby Points of Interest using bakery location
- **Progress Tracking** - Visual progress indicators and step completion
- **Data Persistence** - Save progress at each step
- **Smart Navigation** - Dynamic step dependencies and validation
### Multi-Tenant Administration
- **Tenant Settings** - Configure bakery-specific preferences
- **User Management** - Invite team members and assign roles
- **Subscription Management** - View and upgrade subscription plans
- **Billing Portal** - Stripe-powered billing and invoices
### ML Model Training
- **Training Dashboard** - Monitor ML model training progress
- **WebSocket Live Updates** - Real-time training status and metrics
- **Model Performance** - Compare model versions and accuracy
- **Training History** - Complete log of all training runs
## Technical Capabilities
### Modern React Architecture
- **React 18** - Latest React with concurrent features
- **TypeScript** - Type-safe development with full IntelliSense
- **Vite** - Lightning-fast build tool and dev server
- **Component-Based** - Modular, reusable components
- **Hooks-First** - Modern React patterns with custom hooks
### State Management
- **Zustand** - Lightweight global state management
- **TanStack Query (React Query)** - Server state management with caching
- **Local Storage Persistence** - Persist user preferences
- **Optimistic Updates** - Instant UI feedback before server confirmation
### UI/UX Components
- **Radix UI** - Accessible, unstyled component primitives
- **Tailwind CSS** - Utility-first CSS framework
- **Responsive Design** - Mobile, tablet, and desktop optimized
- **Dark Mode** (planned) - User-selectable theme
- **Accessible** - WCAG 2.1 AA compliant
### Data Visualization
- **Chart.js** - Interactive forecast and analytics charts
- **Recharts** - Declarative React charts for dashboards
- **Custom Visualizations** - Specialized charts for bakery metrics
### Forms & Validation
- **React Hook Form** - Performant form management
- **Zod** - TypeScript-first schema validation
- **Error Handling** - User-friendly validation messages
- **Auto-Save** - Background form persistence
### Real-Time Communication
- **Server-Sent Events (SSE)** - Real-time alert stream from gateway
- **WebSocket** - Live ML training progress updates
- **Auto-Reconnect** - Resilient connection management
- **Event Notifications** - Toast notifications for real-time events
### Internationalization
- **i18next** - Multi-language support
- **Spanish** - Default language for Spanish market
- **English** - Secondary language for international users
- **Date/Number Formatting** - Locale-aware formatting
### API Integration
- **TanStack Query** - Declarative data fetching with caching
- **Axios/Fetch** - HTTP client for REST APIs
- **JWT Authentication** - Token-based auth with auto-refresh
- **Request Interceptors** - Automatic token injection
- **Error Handling** - Centralized error boundary and retry logic
## Business Value
### For Bakery Owners
- **Time Savings** - 15-20 hours/week saved on manual planning
- **Reduced Waste** - Visual demand forecasts reduce overproduction by 20-40%
- **Better Decisions** - Data-driven insights replace guesswork
- **Mobile Access** - Manage bakery from anywhere (responsive design)
- **No Training Required** - Intuitive interface, minimal learning curve
### For Bakery Staff
- **Production Guidance** - Step-by-step recipes on screen
- **Quality Consistency** - Digital quality checklists
- **Inventory Visibility** - Know what's in stock without checking fridges
- **Task Prioritization** - Alerts show what needs immediate attention
### For Multi-Location Bakeries
- **Centralized Control** - Manage all locations from one dashboard
- **Performance Comparison** - Compare KPIs across locations
- **Standardized Processes** - Same interface at all locations
### For Platform Operations
- **Reduced Support Costs** - Intuitive UI reduces support tickets
- **User Engagement** - Real-time updates keep users engaged
- **Feature Discovery** - Guided onboarding increases feature adoption
## Technology Stack
### Core Framework
- **React 18.3** - JavaScript library for user interfaces
- **TypeScript 5.3** - Type-safe JavaScript superset
- **Vite 5.0** - Next-generation frontend tooling
### State Management & Data Fetching
- **Zustand 4.4** - Lightweight state management
- **TanStack Query (React Query) 5.8** - Async state management
- **Axios** - HTTP client
### UI & Styling
- **Radix UI** - Accessible component primitives
- `@radix-ui/react-dialog` - Modal dialogs
- `@radix-ui/react-dropdown-menu` - Dropdown menus
- `@radix-ui/react-select` - Select components
- `@radix-ui/react-tabs` - Tab navigation
- **Tailwind CSS 3.4** - Utility-first CSS framework
- **Headless UI** - Unstyled accessible components
- **Lucide React** - Beautiful, consistent icons
### Data Visualization
- **Chart.js 4.4** - Flexible JavaScript charting
- **react-chartjs-2** - React wrapper for Chart.js
- **Recharts 2.10** - Composable React charts
- **date-fns** - Modern date utility library
### Forms & Validation
- **React Hook Form 7.49** - Performant form library
- **Zod 3.22** - TypeScript-first schema validation
- **@hookform/resolvers** - Zod integration for React Hook Form
### Routing & Navigation
- **React Router 6.20** - Declarative routing for React
- **React Router DOM** - DOM bindings for React Router
### Internationalization
- **i18next 23.7** - Internationalization framework
- **react-i18next 13.5** - React bindings for i18next
### Real-Time Communication
- **EventSource API** - Native SSE support
- **WebSocket API** - Native WebSocket support
- **react-use-websocket** - React WebSocket hook
### Notifications & Feedback
- **react-hot-toast** - Beautiful toast notifications
- **react-loading-skeleton** - Loading placeholders
### Development Tools
- **ESLint** - JavaScript linter
- **Prettier** - Code formatter
- **TypeScript ESLint** - TypeScript linting rules
- **Vite Plugin React** - Fast refresh and JSX transform
## Application Structure
```
frontend/
├── src/
│ ├── components/ # Reusable UI components
│ │ ├── ui/ # Base UI components (buttons, inputs, etc.)
│ │ ├── charts/ # Chart components
│ │ ├── forms/ # Form components
│ │ ├── layout/ # Layout components (header, sidebar, etc.)
│ │ └── domain/ # Domain-specific components
│ │ └── onboarding/ # Onboarding wizard components
│ │ ├── steps/ # Individual step components
│ │ │ ├── POIDetectionStep.tsx # POI detection UI
│ │ │ ├── SetupStep.tsx
│ │ │ └── ...
│ │ ├── context/ # Onboarding wizard context
│ │ └── WizardLayout.tsx
│ ├── pages/ # Page components (routes)
│ │ ├── Dashboard/ # Main dashboard
│ │ ├── Forecasting/ # Forecast management
│ │ ├── Inventory/ # Inventory management
│ │ ├── Production/ # Production planning
│ │ ├── Orders/ # Order management
│ │ ├── Suppliers/ # Supplier management
│ │ ├── Procurement/ # Procurement planning
│ │ ├── Settings/ # User settings
│ │ └── Auth/ # Login/register pages
│ ├── hooks/ # Custom React hooks
│ │ ├── useAuth.ts # Authentication hook
│ │ ├── useSSE.ts # Server-sent events hook
│ │ ├── useWebSocket.ts # WebSocket hook
│ │ └── useQuery.ts # API query hooks
│ ├── stores/ # Zustand stores
│ │ ├── authStore.ts # Authentication state
│ │ ├── alertStore.ts # Alert state
│ │ └── uiStore.ts # UI state (sidebar, theme, etc.)
│ ├── api/ # API client functions
│ │ ├── client/ # API client configuration
│ │ │ └── apiClient.ts # Axios client with tenant injection
│ │ ├── services/ # Service API modules
│ │ │ ├── onboarding.ts # Onboarding API
│ │ │ ├── geocodingApi.ts # Geocoding/address API
│ │ │ └── poiContextApi.ts # POI detection API
│ │ ├── auth.ts # Auth API
│ │ ├── forecasting.ts # Forecasting API
│ │ ├── inventory.ts # Inventory API
│ │ └── ... # Other service APIs
│ ├── types/ # TypeScript type definitions
│ │ ├── api.ts # API response types
│ │ ├── models.ts # Domain model types
│ │ └── components.ts # Component prop types
│ ├── utils/ # Utility functions
│ │ ├── date.ts # Date formatting
│ │ ├── currency.ts # Currency formatting
│ │ ├── validation.ts # Validation helpers
│ │ └── format.ts # General formatting
│ ├── locales/ # i18n translation files
│ │ ├── es/ # Spanish translations
│ │ └── en/ # English translations
│ ├── App.tsx # Root component
│ ├── main.tsx # Application entry point
│ └── router.tsx # Route configuration
├── public/ # Static assets
│ ├── icons/ # App icons
│ └── images/ # Images
├── index.html # HTML template
├── vite.config.ts # Vite configuration
├── tailwind.config.js # Tailwind CSS configuration
├── tsconfig.json # TypeScript configuration
└── package.json # Dependencies
```
## Key Pages & Routes
### Public Routes
- `/login` - User login
- `/register` - User registration
- `/forgot-password` - Password reset
### Onboarding Routes
- `/onboarding` - Multi-step onboarding wizard (15 steps)
- `/onboarding/bakery-type-selection` - Choose bakery type
- `/onboarding/setup` - Basic bakery setup
- `/onboarding/poi-detection` - **POI Detection** - Automatic location context detection
- `/onboarding/upload-sales-data` - Upload historical sales
- `/onboarding/inventory-review` - Review detected products
- `/onboarding/initial-stock-entry` - Initial inventory levels
- `/onboarding/product-categorization` - Product categories
- `/onboarding/suppliers-setup` - Supplier configuration
- `/onboarding/recipes-setup` - Recipe management
- `/onboarding/ml-training` - AI model training
- `/onboarding/setup-review` - Review configuration
- `/onboarding/completion` - Onboarding complete
### Protected Routes (Require Authentication)
- `/dashboard` - Main operational dashboard
- `/forecasting` - Demand forecasting management
- `/forecasting/train` - ML model training
- `/inventory` - Inventory management
- `/inventory/stock` - Stock levels and movements
- `/production` - Production planning
- `/production/batches` - Production batch tracking
- `/production/quality` - Quality control
- `/recipes` - Recipe management
- `/orders` - Customer order management
- `/suppliers` - Supplier management
- `/procurement` - Procurement planning
- `/sales` - Sales analytics
- `/pos` - POS integration settings
- `/settings` - User and tenant settings
- `/settings/team` - Team member management
- `/settings/subscription` - Subscription management
## API Integration
### Authentication Flow
1. **Login**: User enters credentials API returns access token + refresh token
2. **Token Storage**: Tokens stored in Zustand store + localStorage
3. **Request Interceptor**: Axios interceptor adds `Authorization: Bearer {token}` to all requests
4. **Token Refresh**: On 401 error, automatically refresh token and retry request
5. **Logout**: Clear tokens and redirect to login
### TanStack Query Configuration
```typescript
// Automatic background refetching
refetchOnWindowFocus: true
refetchOnReconnect: true
// Stale-while-revalidate caching
staleTime: 5 minutes
cacheTime: 30 minutes
// Retry on failure
retry: 3
retryDelay: exponential backoff
```
### API Client Structure
```typescript
// Base client
const apiClient = axios.create({
baseURL: import.meta.env.VITE_API_URL,
timeout: 30000,
})
// Request interceptor (add JWT)
apiClient.interceptors.request.use((config) => {
const token = authStore.getState().accessToken
config.headers.Authorization = `Bearer ${token}`
return config
})
// Response interceptor (handle token refresh)
apiClient.interceptors.response.use(
(response) => response,
async (error) => {
if (error.response?.status === 401) {
await refreshToken()
return apiClient.request(error.config)
}
throw error
}
)
```
## Real-Time Features
### Server-Sent Events (SSE) for Alerts
```typescript
const useAlertStream = () => {
useEffect(() => {
const eventSource = new EventSource(
`${API_URL}/api/v1/alerts/stream`,
{ withCredentials: true }
)
eventSource.onmessage = (event) => {
const alert = JSON.parse(event.data)
alertStore.addAlert(alert)
toast.notification(alert.message)
}
eventSource.onerror = () => {
// Auto-reconnect on error
setTimeout(() => eventSource.close(), 5000)
}
return () => eventSource.close()
}, [])
}
```
### WebSocket for Training Progress
```typescript
const useTrainingWebSocket = (trainingId: string) => {
const { lastMessage, readyState } = useWebSocket(
`${WS_URL}/api/v1/training/ws?training_id=${trainingId}`
)
useEffect(() => {
if (lastMessage) {
const progress = JSON.parse(lastMessage.data)
updateTrainingProgress(progress)
}
}, [lastMessage])
}
```
## Configuration
### Environment Variables
**API Configuration:**
- `VITE_API_URL` - Backend API gateway URL (e.g., `https://api.bakery-ia.com`)
- `VITE_WS_URL` - WebSocket URL (e.g., `wss://api.bakery-ia.com`)
**Feature Flags:**
- `VITE_ENABLE_DEMO_MODE` - Enable demo mode features (default: false)
- `VITE_ENABLE_ANALYTICS` - Enable analytics tracking (default: true)
**External Services:**
- `VITE_STRIPE_PUBLIC_KEY` - Stripe publishable key for payments
- `VITE_SENTRY_DSN` - Sentry error tracking DSN (optional)
**Build Configuration:**
- `VITE_APP_VERSION` - Application version (from package.json)
- `VITE_BUILD_TIME` - Build timestamp
### Example .env file
```env
VITE_API_URL=http://localhost:8000
VITE_WS_URL=ws://localhost:8000
VITE_ENABLE_DEMO_MODE=true
VITE_STRIPE_PUBLIC_KEY=pk_test_...
```
## Development Setup
### Prerequisites
- Node.js 18+ and npm/yarn/pnpm
- Access to Bakery-IA backend API
### Local Development
```bash
# Install dependencies
cd frontend
npm install
# Set environment variables
cp .env.example .env
# Edit .env with your configuration
# Run development server
npm run dev
# Open browser to http://localhost:5173
```
### Build for Production
```bash
# Create optimized production build
npm run build
# Preview production build locally
npm run preview
```
### Code Quality
```bash
# Run linter
npm run lint
# Run type checking
npm run type-check
# Format code
npm run format
```
## Testing
### Unit Tests (Vitest)
```bash
# Run unit tests
npm run test
# Run tests with coverage
npm run test:coverage
# Run tests in watch mode
npm run test:watch
```
### E2E Tests (Playwright - planned)
```bash
# Run E2E tests
npm run test:e2e
# Run E2E tests in headed mode
npm run test:e2e:headed
```
## Performance Optimization
### Build Optimization
- **Code Splitting** - Lazy load routes for faster initial load
- **Tree Shaking** - Remove unused code from bundles
- **Minification** - Minify JavaScript and CSS
- **Gzip Compression** - Compress assets for faster transfer
- **Image Optimization** - Optimized image formats and sizes
### Runtime Optimization
- **React.memo** - Prevent unnecessary re-renders
- **useMemo/useCallback** - Memoize expensive computations
- **Virtual Scrolling** - Efficiently render large lists
- **Debouncing** - Limit API calls from user input
- **Lazy Loading** - Load components and routes on demand
### Caching Strategy
- **TanStack Query Cache** - 5-minute stale time for most queries
- **Service Worker** (planned) - Offline-first PWA support
- **Asset Caching** - Browser cache for static assets
- **API Response Cache** - Cache GET requests in TanStack Query
## Accessibility (a11y)
### WCAG 2.1 AA Compliance
- **Keyboard Navigation** - All features accessible via keyboard
- **Screen Reader Support** - ARIA labels and semantic HTML
- **Color Contrast** - 4.5:1 contrast ratio for text
- **Focus Indicators** - Visible focus states for interactive elements
- **Alt Text** - Descriptive alt text for images
- **Form Labels** - Proper label associations for inputs
### Radix UI Accessibility
- Built-in keyboard navigation
- ARIA attributes automatically applied
- Focus management
- Screen reader announcements
## Security Measures
### Authentication & Authorization
- **JWT Tokens** - Secure token-based authentication
- **Automatic Token Refresh** - Seamless token renewal
- **HttpOnly Cookies** (planned) - More secure token storage
- **CSRF Protection** - CSRF tokens for state-changing operations
### Data Protection
- **HTTPS Only** (Production) - All communication encrypted
- **XSS Prevention** - React's built-in XSS protection
- **Content Security Policy** - Restrict resource loading
- **Input Sanitization** - Validate and sanitize all user inputs
### Dependency Security
- **npm audit** - Regular security audits
- **Dependabot** - Automatic dependency updates
- **License Scanning** - Ensure license compliance
## Deployment
### Docker Deployment
```dockerfile
# Multi-stage build
FROM node:18-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/nginx.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
```
### Kubernetes Deployment
- **Deployment** - Multiple replicas for high availability
- **Service** - Load balancing across pods
- **Ingress** - HTTPS termination and routing
- **ConfigMap** - Environment-specific configuration
- **HPA** - Horizontal pod autoscaling based on CPU
### CI/CD Pipeline
1. **Lint & Type Check** - Ensure code quality
2. **Unit Tests** - Run test suite
3. **Build** - Create production build
4. **Docker Build** - Create container image
5. **Push to Registry** - Push to container registry
6. **Deploy to Kubernetes** - Update deployment
## Browser Support
- **Chrome** - Latest 2 versions
- **Firefox** - Latest 2 versions
- **Safari** - Latest 2 versions
- **Edge** - Latest 2 versions
- **Mobile Browsers** - iOS Safari 14+, Chrome Android 90+
## Competitive Advantages
1. **Modern Tech Stack** - React 18, TypeScript, Vite for fast development
2. **Real-Time Updates** - SSE and WebSocket for instant feedback
3. **Mobile-First** - Responsive design works on all devices
4. **Offline Support** (planned) - PWA capabilities for unreliable networks
5. **Accessible** - WCAG 2.1 AA compliant for inclusive access
6. **Fast Performance** - Code splitting and caching for sub-second loads
7. **Spanish-First** - UI designed for Spanish bakery workflows
## Future Enhancements
- **Progressive Web App (PWA)** - Offline support and installable
- **Dark Mode** - User-selectable theme
- **Mobile Apps** - React Native iOS/Android apps
- **Advanced Analytics** - Custom dashboard builder
- **Multi-Language** - Support for additional languages
- **Voice Commands** - Hands-free operation in production environment
- **Barcode Scanning** - Native camera integration for inventory
- **Print Templates** - Custom print layouts for labels and reports
---
**For VUE Madrid Business Plan**: The Bakery-IA Frontend Dashboard represents a modern, professional SaaS interface built with industry-leading technologies. The real-time capabilities, mobile-first design, and accessibility compliance make it suitable for bakeries of all sizes, from small artisanal shops to multi-location enterprises. The intuitive interface reduces training costs and increases user adoption, critical factors for successful SaaS businesses in the Spanish market.

View File

@@ -0,0 +1,476 @@
# 🧪 Complete Onboarding Flow Testing Guide
## Overview
This guide explains how to test the complete user registration and onboarding flow using **Playwright with Chrome** (or any browser) in your Bakery-IA application.
## 📦 Prerequisites
1. **Install dependencies** (if not already done):
```bash
cd frontend
npm install
```
2. **Install Playwright browsers** (including Chrome):
```bash
npx playwright install chromium
# Or install all browsers
npx playwright install
```
3. **Set up environment variables** (optional):
```bash
# Create .env file in frontend directory
echo "TEST_USER_EMAIL=ualfaro@gmail.com" >> .env
echo "TEST_USER_PASSWORD=Admin123" >> .env
```
## 🚀 Running the Tests
### Option 1: Run All Onboarding Tests in Chrome (Headless)
```bash
npm run test:e2e -- --project=chromium tests/onboarding/
```
### Option 2: Run with Visible Browser Window (Headed Mode)
**This is the best option to see what's happening!**
```bash
npm run test:e2e:headed -- --project=chromium tests/onboarding/
```
### Option 3: Run in Debug Mode (Step-by-Step)
```bash
npm run test:e2e:debug -- tests/onboarding/complete-registration-flow.spec.ts
```
This will:
- Open Playwright Inspector
- Allow you to step through each test action
- Pause on failures
### Option 4: Run with Interactive UI Mode
```bash
npm run test:e2e:ui -- --project=chromium
```
This opens Playwright's UI where you can:
- Select specific tests to run
- Watch tests in real-time
- Time-travel through test steps
- See screenshots and traces
### Option 5: Run Specific Test
```bash
# Run only the complete registration flow test
npx playwright test tests/onboarding/complete-registration-flow.spec.ts --headed --project=chromium
# Run only wizard navigation tests
npx playwright test tests/onboarding/wizard-navigation.spec.ts --headed --project=chromium
# Run only file upload tests
npx playwright test tests/onboarding/file-upload.spec.ts --headed --project=chromium
```
## 🎯 What Gets Tested
### 1. Complete Registration Flow (`complete-registration-flow.spec.ts`)
**Phase 1: User Registration**
- ✅ Navigate to registration page
- ✅ Fill basic information (name, email, password)
- ✅ Password validation (strength requirements)
- ✅ Password confirmation matching
- ✅ Terms and conditions acceptance
- ✅ Marketing and analytics consent
- ✅ Subscription plan selection
- ✅ Payment information entry
- ✅ Redirect to onboarding
**Phase 2: Onboarding Wizard**
- ✅ Bakery type selection (Production/Retail/Mixed)
- ✅ Tenant setup (bakery registration)
- ✅ Sales data upload (or skip)
- ✅ Inventory review
- ✅ Initial stock entry
- ✅ Suppliers setup
- ✅ Recipes setup (conditional)
- ✅ ML training
- ✅ Completion and redirect to dashboard
**Validation Tests**
- ✅ Weak password rejection
- ✅ Invalid email format
- ✅ Password mismatch detection
- ✅ Required terms acceptance
### 2. Wizard Navigation (`wizard-navigation.spec.ts`)
- ✅ Step progression
- ✅ Backward navigation
- ✅ Progress indicator visibility
- ✅ Skip functionality
- ✅ Step validation
### 3. File Upload (`file-upload.spec.ts`)
- ✅ CSV file upload
- ✅ Drag and drop
- ✅ File type validation
- ✅ Invalid file rejection
## 📁 Test File Structure
```
frontend/tests/
├── auth.setup.ts # Authentication setup
├── helpers/
│ └── utils.ts # Test utilities
└── onboarding/
├── complete-registration-flow.spec.ts # ⭐ Full flow test (NEW)
├── wizard-navigation.spec.ts # Wizard step navigation
└── file-upload.spec.ts # File upload tests
```
## 🎬 Step-by-Step: How to Test Manually with Playwright
### Method 1: Using Playwright Codegen (Record Your Actions)
This is perfect for creating new tests or understanding the flow:
```bash
# Start the dev server first
npm run dev
# In another terminal, start Playwright codegen
npm run test:e2e:codegen
```
Then:
1. Navigate to `http://localhost:5173/register`
2. Go through the registration process manually
3. Playwright will record all your actions
4. Copy the generated code to create new tests
### Method 2: Run Existing Test in Headed Mode
```bash
# Make sure your dev server is running
npm run dev
# In another terminal, run the test with Chrome visible
npm run test:e2e:headed -- --project=chromium tests/onboarding/complete-registration-flow.spec.ts
```
You'll see Chrome open and automatically:
1. Navigate to registration
2. Fill in the form
3. Select a plan
4. Complete payment
5. Go through onboarding steps
6. Reach the dashboard
## 🐛 Debugging Failed Tests
### View Test Report
After tests run, view the HTML report:
```bash
npx playwright show-report
```
### View Screenshots
Failed tests automatically capture screenshots:
```
frontend/test-results/
├── screenshots/
│ └── onboarding-stuck-step-X.png
└── onboarding-completed.png
```
### View Test Traces
For detailed debugging with timeline:
```bash
# Run with trace enabled
npx playwright test --trace on
# View trace
npx playwright show-trace trace.zip
```
## 🔧 Configuration
### Change Base URL
Edit `frontend/playwright.config.ts`:
```typescript
use: {
baseURL: 'http://localhost:5173', // Change this
}
```
Or set environment variable:
```bash
export PLAYWRIGHT_BASE_URL=http://your-app.com
npm run test:e2e
```
### Run Against Different Environment
```bash
# Test against staging
PLAYWRIGHT_BASE_URL=https://staging.bakery-ia.com npm run test:e2e:headed
# Test against production (careful!)
PLAYWRIGHT_BASE_URL=https://app.bakery-ia.com npm run test:e2e
```
### Test with Different Browsers
```bash
# Firefox
npm run test:e2e:headed -- --project=firefox
# Safari (WebKit)
npm run test:e2e:headed -- --project=webkit
# Mobile Chrome
npm run test:e2e:headed -- --project="Mobile Chrome"
# All browsers
npm run test:e2e
```
## 💡 Tips & Best Practices
### 1. Run Dev Server First
The tests expect the app to be running. Start it with:
```bash
npm run dev
```
Or let Playwright auto-start it (configured in `playwright.config.ts`):
```typescript
webServer: {
command: 'npm run dev',
url: 'http://localhost:5173',
reuseExistingServer: !process.env.CI,
}
```
### 2. Use Headed Mode for Debugging
Always use `--headed` when developing tests:
```bash
npm run test:e2e:headed
```
### 3. Use `.only` for Single Test
Temporarily run just one test:
```typescript
test.only('should complete onboarding', async ({ page }) => {
// ...
});
```
### 4. Use `.skip` to Skip Tests
Skip flaky tests temporarily:
```typescript
test.skip('flaky test', async ({ page }) => {
// ...
});
```
### 5. Slow Down Tests for Visibility
Add `page.setDefaultTimeout()` or use `slow`:
```typescript
test('my test', async ({ page }) => {
test.slow(); // Triples the timeout
await page.waitForTimeout(1000); // Wait 1 second
});
```
## 🔐 Testing with Authentication
The tests use authenticated state stored in `tests/.auth/user.json`. This is created by `auth.setup.ts`.
To update test credentials:
```bash
# Edit the file
vim frontend/tests/auth.setup.ts
# Or set environment variables
export TEST_USER_EMAIL=your.email@example.com
export TEST_USER_PASSWORD=YourPassword123
```
## 📊 Test Data
### Default Test User (for existing tests)
```
Email: ualfaro@gmail.com
Password: Admin123
```
### New Test Users (auto-generated)
The `complete-registration-flow.spec.ts` test creates unique users on each run:
```javascript
const testUser = {
fullName: `Test User ${Date.now()}`,
email: `test.user.${Date.now()}@bakery-test.com`,
password: 'SecurePass123!@#'
};
```
### Stripe Test Cards
For payment testing, use Stripe test cards:
```
Success: 4242 4242 4242 4242
Decline: 4000 0000 0000 0002
3D Secure: 4000 0027 6000 3184
Expiry: Any future date (e.g., 12/34)
CVC: Any 3 digits (e.g., 123)
```
## 🎯 Common Test Scenarios
### Test 1: Complete Happy Path
```bash
# Run the complete flow test
npx playwright test tests/onboarding/complete-registration-flow.spec.ts:6 --headed
```
Line 6 is the "should complete full registration and onboarding flow for starter plan" test.
### Test 2: Test Validation Errors
```bash
# Run validation test
npx playwright test tests/onboarding/complete-registration-flow.spec.ts -g "validation errors" --headed
```
### Test 3: Test Backward Navigation
```bash
# Run backward navigation test
npx playwright test tests/onboarding/wizard-navigation.spec.ts -g "backward navigation" --headed
```
## 📹 Recording Test Videos
Videos are automatically recorded on failure. To record all tests:
Edit `playwright.config.ts`:
```typescript
use: {
video: 'on', // or 'retain-on-failure', 'on-first-retry'
}
```
## 🌐 Testing in Different Languages
The app supports multiple languages. Test with different locales:
```bash
# Test in Spanish (default)
npm run test:e2e:headed
# Test in English
# You'd need to click the language selector in the test
```
## ❓ Troubleshooting
### Issue: "Timed out waiting for webServer"
**Solution:** Your dev server isn't starting. Run it manually:
```bash
npm run dev
```
Then run tests with:
```bash
PLAYWRIGHT_BASE_URL=http://localhost:5173 npm run test:e2e -- --config=playwright.config.ts
```
Or edit `playwright.config.ts` and set:
```typescript
webServer: {
reuseExistingServer: true, // Use already running server
}
```
### Issue: "Test failed: element not found"
**Solutions:**
1. Increase timeout: `await element.waitFor({ timeout: 10000 })`
2. Check selectors are correct for your language
3. Run in headed mode to see what's happening
4. Use Playwright Inspector: `npm run test:e2e:debug`
### Issue: "Authentication failed"
**Solution:** Update test credentials in `tests/auth.setup.ts` or use environment variables.
### Issue: "Payment test fails"
**Solution:**
- Check if bypass payment toggle is enabled in your test environment
- Verify Stripe is configured with test keys
- Use valid Stripe test card numbers
## 📚 Additional Resources
- [Playwright Documentation](https://playwright.dev)
- [Playwright Best Practices](https://playwright.dev/docs/best-practices)
- [Playwright Debugging](https://playwright.dev/docs/debug)
- [Playwright CI/CD](https://playwright.dev/docs/ci)
## 🎓 Next Steps
1. **Run the existing tests** to understand the flow
2. **Use Playwright Codegen** to record additional test scenarios
3. **Add more test cases** for edge cases (enterprise tier, production bakery, etc.)
4. **Set up CI/CD** to run tests automatically on pull requests
5. **Monitor test flakiness** and improve reliability
---
Happy Testing! 🚀
For questions or issues, check the [Playwright Discord](https://discord.gg/playwright-807756831384403968) or create an issue in the repository.

View File

@@ -0,0 +1,195 @@
# 🚀 Quick Test Commands Reference
## ⚡ Most Used Commands
### 1. Run Tests in Chrome (Watch Mode)
```bash
npm run test:e2e:headed -- --project=chromium tests/onboarding/
```
**Use this for:** Watching tests run in a visible Chrome window
### 2. Run Tests in UI Mode (Best for Development)
```bash
npm run test:e2e:ui
```
**Use this for:** Interactive test development and debugging
### 3. Debug Specific Test
```bash
npm run test:e2e:debug -- tests/onboarding/complete-registration-flow.spec.ts
```
**Use this for:** Step-by-step debugging with Playwright Inspector
### 4. Record New Tests (Codegen)
```bash
# Start dev server first
npm run dev
# In another terminal
npm run test:e2e:codegen
```
**Use this for:** Recording your actions to generate test code
### 5. Run Complete Flow Test Only
```bash
npx playwright test tests/onboarding/complete-registration-flow.spec.ts --headed --project=chromium
```
**Use this for:** Testing the full registration + onboarding flow
---
## 📋 All Available Commands
| Command | Description |
|---------|-------------|
| `npm run test:e2e` | Run all E2E tests (headless) |
| `npm run test:e2e:ui` | Open Playwright UI mode |
| `npm run test:e2e:headed` | Run tests with visible browser |
| `npm run test:e2e:debug` | Debug tests with Playwright Inspector |
| `npm run test:e2e:report` | View last test report |
| `npm run test:e2e:codegen` | Record actions to generate tests |
---
## 🎯 Test Specific Scenarios
### Test Complete Registration Flow
```bash
npx playwright test complete-registration-flow --headed
```
### Test Wizard Navigation Only
```bash
npx playwright test wizard-navigation --headed
```
### Test File Upload Only
```bash
npx playwright test file-upload --headed
```
### Test with Specific Browser
```bash
# Chrome
npx playwright test --project=chromium --headed
# Firefox
npx playwright test --project=firefox --headed
# Safari
npx playwright test --project=webkit --headed
# Mobile Chrome
npx playwright test --project="Mobile Chrome" --headed
```
### Run Single Test by Name
```bash
npx playwright test -g "should complete full registration" --headed
```
---
## 🐛 Debugging Commands
### View Last Test Report
```bash
npx playwright show-report
```
### Run with Trace
```bash
npx playwright test --trace on
```
### View Trace File
```bash
npx playwright show-trace trace.zip
```
### Run in Slow Motion
```bash
npx playwright test --headed --slow-mo=1000
```
---
## ⚙️ Before Running Tests
### 1. Make Sure Dev Server is Running
```bash
npm run dev
```
### 2. Or Let Playwright Auto-Start It
The `playwright.config.ts` is already configured to auto-start the dev server.
---
## 🔧 Configuration
### Change Base URL
```bash
PLAYWRIGHT_BASE_URL=http://localhost:5173 npm run test:e2e:headed
```
### Update Test User Credentials
Edit `frontend/tests/auth.setup.ts` or set:
```bash
export TEST_USER_EMAIL=your.email@example.com
export TEST_USER_PASSWORD=YourPassword123
```
---
## 📊 Example: Full Testing Workflow
```bash
# 1. Start dev server
npm run dev
# 2. In another terminal, run tests in UI mode
npm run test:e2e:ui
# 3. Select "complete-registration-flow.spec.ts"
# 4. Click "Watch" and "Show browser"
# 5. See the magic happen! ✨
```
---
## 🎬 Pro Tips
1. **Always use `--headed` when developing tests** - you need to see what's happening
2. **Use UI mode for test development** - it's the best experience
3. **Use `test.only()` to run a single test** - faster iteration
4. **Use `page.pause()` to pause execution** - inspect state mid-test
5. **Check test-results/ folder for screenshots** - helpful for debugging
---
## 📁 Test Files Location
```
frontend/tests/onboarding/
├── complete-registration-flow.spec.ts ← Full flow (NEW!)
├── wizard-navigation.spec.ts ← Wizard steps
└── file-upload.spec.ts ← File uploads
```
---
## 🆘 Quick Help
**Command not working?**
1. Make sure you're in the `frontend/` directory
2. Run `npm install` to ensure dependencies are installed
3. Run `npx playwright install chromium` to install browsers
4. Check that dev server is running on port 5173
**Need more help?**
- Read the full guide: `TESTING_ONBOARDING_GUIDE.md`
- Check Playwright docs: https://playwright.dev
- View test report: `npx playwright show-report`

30
frontend/index.html Normal file
View File

@@ -0,0 +1,30 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="Plataforma inteligente de gestión para panaderías con predicción de demanda impulsada por IA" />
<meta name="theme-color" content="#f97316" />
<!-- PWA Meta Tags -->
<link rel="manifest" href="/manifest.json" />
<link rel="apple-touch-icon" href="/icons/icon-192.png" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<!-- Fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Poppins:wght@600;700&display=swap" rel="stylesheet" />
<!-- Runtime configuration - MUST load before app code (Kubernetes deployment) -->
<script src="/runtime-config.js"></script>
<title>BakeWise - Gestión Inteligente para Panaderías</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

12
frontend/nginx-main.conf Normal file
View File

@@ -0,0 +1,12 @@
pid /var/run/nginx/nginx.pid;
worker_processes auto;
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
include /etc/nginx/conf.d/*.conf;
}

122
frontend/nginx.conf Normal file
View File

@@ -0,0 +1,122 @@
# Nginx configuration for Bakery IA Frontend
# This file is used inside the container at /etc/nginx/conf.d/default.conf
server {
listen 3000;
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;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://fonts.googleapis.com https://js.stripe.com; script-src-elem 'self' 'unsafe-inline' https://js.stripe.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: https:; connect-src 'self' http://localhost http://localhost:8000 http://localhost:8001 http://localhost:8006 ws: wss:; frame-src https://js.stripe.com;" always;
# 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;
# Note: API routing is handled by ingress, not by this nginx
# The frontend makes requests to /api which are routed by the ingress controller
# Source map files - serve with proper CORS headers and content type
# Note: These are typically only needed in development, but served in production for error reporting
location ~* ^/assets/.*\.map$ {
# Short cache time to avoid mismatches with JS files
expires 1m;
add_header Cache-Control "public, must-revalidate";
add_header Access-Control-Allow-Origin "*";
add_header Access-Control-Allow-Methods "GET";
add_header Access-Control-Allow-Headers "Content-Type";
add_header Content-Type "application/json";
# Disable access logging for source maps as they're requested frequently
access_log off;
try_files $uri =404;
}
# Static assets with appropriate caching
# Note: JS/CSS files have content hashes for cache busting, but use shorter cache times to handle deployment issues
location ~* ^/assets/.*\.(js|css)$ {
expires 1h;
add_header Cache-Control "public";
add_header Vary Accept-Encoding;
add_header Access-Control-Allow-Origin "*";
access_log off;
try_files $uri =404;
}
# Static assets that don't change often (images, fonts) can have longer cache times
location ~* ^/assets/.*\.(png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
add_header Vary Accept-Encoding;
add_header Access-Control-Allow-Origin "*";
access_log off;
try_files $uri =404;
}
# Handle JS and CSS files anywhere in the structure (for dynamic imports) with shorter cache
location ~* \.(js|css)$ {
expires 1h;
add_header Cache-Control "public";
add_header Vary Accept-Encoding;
access_log off;
try_files $uri =404;
}
# Special handling for PWA assets
location ~* \.(webmanifest|manifest\.json)$ {
expires 1d;
add_header Cache-Control "public";
add_header Content-Type application/manifest+json;
}
location = /sw.js {
expires 1d;
add_header Cache-Control "public";
add_header Content-Type application/javascript;
}
# Main location block for SPA routing
location / {
try_files $uri $uri/ @fallback;
}
# Fallback for SPA routing - serve index.html
location @fallback {
rewrite ^.*$ /index.html last;
}
# Health check endpoint
location /health {
access_log off;
return 200 "healthy\n";
add_header Content-Type text/plain;
}
# Deny access to hidden files
location ~ /\. {
deny all;
access_log off;
log_not_found off;
}
# Logging
access_log /var/log/nginx/access.log;
error_log /var/log/nginx/error.log warn;
}

17631
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

120
frontend/package.json Normal file
View File

@@ -0,0 +1,120 @@
{
"name": "bakery-ai-frontend",
"private": true,
"version": "2.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"build:check": "tsc && vite build",
"preview": "vite preview",
"test": "vitest",
"test:ui": "vitest --ui",
"test:coverage": "vitest --coverage",
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui",
"test:e2e:headed": "playwright test --headed",
"test:e2e:debug": "playwright test --debug",
"test:e2e:report": "playwright show-report",
"test:e2e:codegen": "playwright codegen http://localhost:5173",
"test:e2e:k8s": "playwright test --config=playwright.k8s.config.ts",
"test:e2e:k8s:ui": "playwright test --config=playwright.k8s.config.ts --ui",
"test:e2e:k8s:headed": "playwright test --config=playwright.k8s.config.ts --headed",
"test:e2e:k8s:debug": "playwright test --config=playwright.k8s.config.ts --debug",
"test:e2e:k8s:codegen": "playwright codegen http://localhost",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"format": "prettier --write \"src/**/*.{js,jsx,ts,tsx,json,css,md}\"",
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build",
"type-check": "tsc --noEmit"
},
"dependencies": {
"@hookform/resolvers": "^3.3.2",
"@opentelemetry/api": "^1.9.0",
"@opentelemetry/exporter-metrics-otlp-http": "^0.210.0",
"@opentelemetry/exporter-trace-otlp-http": "^0.210.0",
"@opentelemetry/resources": "^2.4.0",
"@opentelemetry/sdk-metrics": "^2.4.0",
"@opentelemetry/sdk-trace-web": "^2.4.0",
"@opentelemetry/semantic-conventions": "^1.39.0",
"@radix-ui/react-accordion": "^1.1.2",
"@radix-ui/react-checkbox": "^1.0.4",
"@radix-ui/react-dialog": "^1.0.5",
"@radix-ui/react-dropdown-menu": "^2.0.6",
"@radix-ui/react-progress": "^1.0.3",
"@radix-ui/react-select": "^2.0.0",
"@radix-ui/react-switch": "^1.0.3",
"@radix-ui/react-tabs": "^1.0.4",
"@radix-ui/react-tooltip": "^1.0.7",
"@stripe/react-stripe-js": "^3.0.0",
"@stripe/stripe-js": "^4.0.0",
"@tanstack/react-query": "^5.12.0",
"axios": "^1.6.2",
"chart.js": "^4.5.0",
"class-variance-authority": "^0.7.0",
"clsx": "^2.0.0",
"date-fns": "^2.30.0",
"date-fns-tz": "^2.0.0",
"driver.js": "^1.3.6",
"event-source-polyfill": "^1.0.31",
"framer-motion": "^10.18.0",
"i18next": "^23.7.0",
"i18next-icu": "^2.4.1",
"immer": "^10.0.3",
"leaflet": "^1.9.4",
"lucide-react": "^0.294.0",
"papaparse": "^5.4.1",
"react": "^18.2.0",
"react-chartjs-2": "^5.3.0",
"react-dom": "^18.2.0",
"react-dropzone": "^14.2.3",
"react-error-boundary": "^6.0.0",
"react-hook-form": "^7.48.0",
"react-hot-toast": "^2.4.1",
"react-i18next": "^13.5.0",
"react-leaflet": "^4.2.1",
"react-router-dom": "^6.20.0",
"recharts": "^2.10.0",
"tailwind-merge": "^2.1.0",
"xlsx": "^0.18.5",
"zod": "^3.22.4",
"zustand": "^4.5.7"
},
"devDependencies": {
"@playwright/test": "^1.56.1",
"@storybook/addon-essentials": "^7.6.0",
"@storybook/addon-interactions": "^7.6.0",
"@storybook/addon-links": "^7.6.0",
"@storybook/blocks": "^7.6.0",
"@storybook/react-vite": "^7.6.0",
"@storybook/testing-library": "^0.2.2",
"@tailwindcss/aspect-ratio": "^0.4.2",
"@tailwindcss/forms": "^0.5.10",
"@tailwindcss/typography": "^0.5.16",
"@tanstack/react-query-devtools": "^5.85.5",
"@testing-library/jest-dom": "^6.1.0",
"@testing-library/react": "^14.1.0",
"@testing-library/user-event": "^14.5.0",
"@types/node": "^20.10.0",
"@types/papaparse": "^5.3.14",
"@types/react": "^18.2.43",
"@types/react-dom": "^18.2.17",
"@typescript-eslint/eslint-plugin": "^6.14.0",
"@typescript-eslint/parser": "^6.14.0",
"@vitejs/plugin-react": "^4.2.0",
"@vitest/ui": "^1.0.0",
"autoprefixer": "^10.4.16",
"eslint": "^8.55.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.5",
"msw": "^2.0.0",
"postcss": "^8.4.32",
"prettier": "^3.1.0",
"prettier-plugin-tailwindcss": "^0.5.0",
"tailwindcss": "^3.3.0",
"typescript": "^5.9.2",
"vite": "^5.0.0",
"vite-plugin-pwa": "^0.17.0",
"vitest": "^1.0.0"
}
}

View File

@@ -0,0 +1,110 @@
import { defineConfig, devices } from '@playwright/test';
/**
* Playwright configuration for Bakery-IA E2E tests
* See https://playwright.dev/docs/test-configuration
*/
export default defineConfig({
testDir: './tests',
/* Run tests in files in parallel */
fullyParallel: true,
/* Fail the build on CI if you accidentally left test.only in the source code */
forbidOnly: !!process.env.CI,
/* Retry on CI only */
retries: process.env.CI ? 2 : 0,
/* Opt out of parallel tests on CI */
workers: process.env.CI ? 1 : undefined,
/* Reporter to use */
reporter: [
['html', { outputFolder: 'playwright-report' }],
['json', { outputFile: 'test-results/results.json' }],
['list'],
],
/* Shared settings for all the projects below */
use: {
/* Base URL to use in actions like `await page.goto('/')` */
baseURL: process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:5173',
/* Collect trace when retrying the failed test */
trace: 'on-first-retry',
/* Take screenshot on failure */
screenshot: 'only-on-failure',
/* Record video on failure */
video: 'retain-on-failure',
/* Maximum time each action (click, fill, etc) can take */
actionTimeout: 10000,
},
/* Configure projects for major browsers */
projects: [
// Setup project to authenticate once
{
name: 'setup',
testMatch: /.*\.setup\.ts/,
},
// Desktop browsers
{
name: 'chromium',
use: {
...devices['Desktop Chrome'],
storageState: 'tests/.auth/user.json',
},
dependencies: ['setup'],
},
{
name: 'firefox',
use: {
...devices['Desktop Firefox'],
storageState: 'tests/.auth/user.json',
},
dependencies: ['setup'],
},
{
name: 'webkit',
use: {
...devices['Desktop Safari'],
storageState: 'tests/.auth/user.json',
},
dependencies: ['setup'],
},
// Mobile viewports
{
name: 'Mobile Chrome',
use: {
...devices['Pixel 5'],
storageState: 'tests/.auth/user.json',
},
dependencies: ['setup'],
},
{
name: 'Mobile Safari',
use: {
...devices['iPhone 13'],
storageState: 'tests/.auth/user.json',
},
dependencies: ['setup'],
},
],
/* Run your local dev server before starting the tests */
webServer: {
command: 'npm run dev',
url: 'http://localhost:5173',
reuseExistingServer: !process.env.CI,
timeout: 120 * 1000,
},
});

View File

@@ -0,0 +1,119 @@
import { defineConfig, devices } from '@playwright/test';
/**
* Playwright configuration for Bakery-IA E2E tests against local Kubernetes environment
* This config is specifically for testing against Tilt-managed services
*
* Usage:
* npm run test:e2e:k8s
* npx playwright test --config=playwright.k8s.config.ts
*
* Prerequisites:
* - Tilt must be running (`tilt up`)
* - Frontend service must be accessible at http://localhost (via ingress)
*/
export default defineConfig({
testDir: './tests',
/* Run tests in files in parallel */
fullyParallel: true,
/* Fail the build on CI if you accidentally left test.only in the source code */
forbidOnly: !!process.env.CI,
/* Retry on CI only */
retries: process.env.CI ? 2 : 0,
/* Opt out of parallel tests on CI */
workers: process.env.CI ? 1 : undefined,
/* Reporter to use */
reporter: [
['html', { outputFolder: 'playwright-report' }],
['json', { outputFile: 'test-results/results.json' }],
['list'],
],
/* Shared settings for all the projects below */
use: {
/* Base URL points to K8s ingress - override with PLAYWRIGHT_BASE_URL env var if needed */
baseURL: process.env.PLAYWRIGHT_BASE_URL || 'http://localhost',
/* Collect trace when retrying the failed test */
trace: 'on-first-retry',
/* Take screenshot on failure */
screenshot: 'only-on-failure',
/* Record video on failure */
video: 'retain-on-failure',
/* Maximum time each action (click, fill, etc) can take */
actionTimeout: 10000,
/* Increase navigation timeout for K8s environment (ingress routing may be slower) */
navigationTimeout: 30000,
},
/* Configure projects for major browsers */
projects: [
// Setup project to authenticate once
{
name: 'setup',
testMatch: /.*\.setup\.ts/,
},
// Desktop browsers
{
name: 'chromium',
use: {
...devices['Desktop Chrome'],
storageState: 'tests/.auth/user.json',
},
dependencies: ['setup'],
},
{
name: 'firefox',
use: {
...devices['Desktop Firefox'],
storageState: 'tests/.auth/user.json',
},
dependencies: ['setup'],
},
{
name: 'webkit',
use: {
...devices['Desktop Safari'],
storageState: 'tests/.auth/user.json',
},
dependencies: ['setup'],
},
// Mobile viewports
{
name: 'Mobile Chrome',
use: {
...devices['Pixel 5'],
storageState: 'tests/.auth/user.json',
},
dependencies: ['setup'],
},
{
name: 'Mobile Safari',
use: {
...devices['iPhone 13'],
storageState: 'tests/.auth/user.json',
},
dependencies: ['setup'],
},
],
/*
* DO NOT start local dev server - Tilt manages the services
* The frontend is served through K8s ingress
*/
// webServer: undefined,
});

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><text y="14" font-size="14">🍞</text></svg>

After

Width:  |  Height:  |  Size: 106 B

View File

@@ -0,0 +1,114 @@
{
"name": "BakeWise - Gestión Inteligente para Panaderías",
"short_name": "BakeWise",
"description": "Plataforma inteligente de gestión para panaderías con predicción de demanda impulsada por IA",
"start_url": "/",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#f97316",
"orientation": "any",
"categories": ["business", "productivity", "food"],
"lang": "es",
"dir": "ltr",
"icons": [
{
"src": "/icons/icon-72.png",
"sizes": "72x72",
"type": "image/png",
"purpose": "any"
},
{
"src": "/icons/icon-96.png",
"sizes": "96x96",
"type": "image/png",
"purpose": "any"
},
{
"src": "/icons/icon-128.png",
"sizes": "128x128",
"type": "image/png",
"purpose": "any"
},
{
"src": "/icons/icon-144.png",
"sizes": "144x144",
"type": "image/png",
"purpose": "any"
},
{
"src": "/icons/icon-152.png",
"sizes": "152x152",
"type": "image/png",
"purpose": "any"
},
{
"src": "/icons/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
"src": "/icons/icon-384.png",
"sizes": "384x384",
"type": "image/png",
"purpose": "any"
},
{
"src": "/icons/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
},
{
"src": "/icons/icon-maskable.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
],
"screenshots": [
{
"src": "/screenshots/dashboard.png",
"sizes": "1280x720",
"type": "image/png",
"label": "Panel de Control Principal"
},
{
"src": "/screenshots/inventory.png",
"sizes": "1280x720",
"type": "image/png",
"label": "Gestión de Inventario"
},
{
"src": "/screenshots/forecasting.png",
"sizes": "1280x720",
"type": "image/png",
"label": "Predicción de Demanda con IA"
}
],
"shortcuts": [
{
"name": "Panel de Control",
"short_name": "Dashboard",
"description": "Ver panel de control principal",
"url": "/app/dashboard",
"icons": [{ "src": "/icons/dashboard-96.png", "sizes": "96x96" }]
},
{
"name": "Inventario",
"short_name": "Inventario",
"description": "Gestionar inventario",
"url": "/app/operations/inventory",
"icons": [{ "src": "/icons/inventory-96.png", "sizes": "96x96" }]
},
{
"name": "Predicciones",
"short_name": "Predicciones",
"description": "Ver predicciones de demanda",
"url": "/app/analytics/forecasting",
"icons": [{ "src": "/icons/forecast-96.png", "sizes": "96x96" }]
}
],
"related_applications": [],
"prefer_related_applications": false
}

139
frontend/public/sw.js Normal file
View File

@@ -0,0 +1,139 @@
const CACHE_NAME = 'bakery-ai-v2.0.0';
const urlsToCache = [
'/',
'/index.html',
'/manifest.json',
'/manifest.webmanifest',
'/favicon.ico',
];
// Install event - cache assets
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => {
console.log('Opened cache');
return cache.addAll(urlsToCache);
})
);
self.skipWaiting();
});
// Activate event - clean up old caches
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((cacheNames) => {
return Promise.all(
cacheNames
.filter((cacheName) => cacheName !== CACHE_NAME)
.map((cacheName) => caches.delete(cacheName))
);
})
);
self.clients.claim();
});
// Fetch event - serve from cache, fallback to network
self.addEventListener('fetch', (event) => {
// Skip cross-origin requests
if (!event.request.url.startsWith(self.location.origin)) {
return;
}
// API calls - network first, cache fallback
if (event.request.url.includes('/api/')) {
event.respondWith(
fetch(event.request)
.then((response) => {
// Clone the response before caching
const responseToCache = response.clone();
caches.open(CACHE_NAME).then((cache) => {
cache.put(event.request, responseToCache);
});
return response;
})
.catch(() => {
return caches.match(event.request);
})
);
return;
}
// Static assets - network first, cache fallback (for versioned assets)
if (event.request.destination === 'script' || event.request.destination === 'style' || event.request.destination === 'image') {
event.respondWith(
fetch(event.request).then((response) => {
// Clone the response before caching
const responseToCache = response.clone();
caches.open(CACHE_NAME).then((cache) => {
cache.put(event.request, responseToCache);
});
return response;
}).catch(() => {
return caches.match(event.request);
})
);
} else {
// Other requests - cache first, network fallback
event.respondWith(
caches.match(event.request).then((response) => {
return response || fetch(event.request);
})
);
}
});
// Background sync for offline actions
self.addEventListener('sync', (event) => {
if (event.tag === 'sync-inventory') {
event.waitUntil(syncInventoryData());
}
});
// Push notifications
self.addEventListener('push', (event) => {
const options = {
body: event.data ? event.data.text() : 'Nueva notificación de Bakery AI',
icon: '/icons/icon-192.png',
badge: '/icons/badge-72.png',
vibrate: [100, 50, 100],
data: {
dateOfArrival: Date.now(),
primaryKey: 1,
},
};
event.waitUntil(
self.registration.showNotification('Bakery AI', options)
);
});
// Notification click handler
self.addEventListener('notificationclick', (event) => {
event.notification.close();
event.waitUntil(
clients.openWindow('/')
);
});
// Helper function for background sync
async function syncInventoryData() {
try {
const cache = await caches.open(CACHE_NAME);
const requests = await cache.keys();
const pendingRequests = requests.filter(
req => req.url.includes('/api/') && req.method === 'POST'
);
for (const request of pendingRequests) {
try {
await fetch(request);
await cache.delete(request);
} catch (error) {
console.error('Failed to sync:', error);
}
}
} catch (error) {
console.error('Sync failed:', error);
}
}

124
frontend/src/App.tsx Normal file
View File

@@ -0,0 +1,124 @@
import { Suspense } from 'react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import { BrowserRouter, useNavigate } from 'react-router-dom';
import { I18nextProvider } from 'react-i18next';
import { Toaster } from 'react-hot-toast';
import { ErrorBoundary } from './components/layout/ErrorBoundary';
import { LoadingSpinner } from './components/ui';
import { AppRouter } from './router/AppRouter';
import { ThemeProvider } from './contexts/ThemeContext';
import { AuthProvider } from './contexts/AuthContext';
import { SSEProvider } from './contexts/SSEContext';
import { SubscriptionEventsProvider } from './contexts/SubscriptionEventsContext';
import { EnterpriseProvider } from './contexts/EnterpriseContext';
import GlobalSubscriptionHandler from './components/auth/GlobalSubscriptionHandler';
import { CookieBanner } from './components/ui/CookieConsent';
import { useTenantInitializer } from './stores/useTenantInitializer';
import i18n from './i18n';
// PHASE 1 OPTIMIZATION: Optimized React Query configuration
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 5 * 60 * 1000, // 5 minutes
gcTime: 10 * 60 * 1000, // 10 minutes
retry: 2, // Reduced from 3 to 2 for faster failure
refetchOnWindowFocus: true, // Changed to true for better UX
refetchOnMount: 'stale', // Only refetch if data is stale (not always)
structuralSharing: true, // Enable request deduplication
},
},
});
function AppContent() {
const navigate = useNavigate();
// Initialize tenant data when user is authenticated or in demo mode
useTenantInitializer();
return (
<>
<Suspense fallback={<LoadingSpinner overlay />}>
<AppRouter />
<GlobalSubscriptionHandler />
<CookieBanner onPreferencesClick={() => navigate('/cookie-preferences')} />
<Toaster
position="top-right"
toastOptions={{
// Default toast options
duration: 4000,
style: {
background: 'white',
color: 'black',
border: '1px solid #e5e7eb',
borderRadius: '0.5rem',
boxShadow: '0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05)',
minWidth: '300px',
},
success: {
style: {
background: '#f0fdf4', // bg-green-50 equivalent
color: '#166534', // text-green-800 equivalent
border: '1px solid #bbf7d0', // border-green-200 equivalent
},
},
error: {
style: {
background: '#fef2f2', // bg-red-50 equivalent
color: '#b91c1c', // text-red-800 equivalent
border: '1px solid #fecaca', // border-red-200 equivalent
},
},
warning: {
style: {
background: '#fffbf0', // bg-yellow-50 equivalent
color: '#92400e', // text-yellow-800 equivalent
border: '1px solid #fde68a', // border-yellow-200 equivalent
},
},
info: {
style: {
background: '#eff6ff', // bg-blue-50 equivalent
color: '#1e40af', // text-blue-800 equivalent
border: '1px solid #bfdbfe', // border-blue-200 equivalent
},
},
}}
/>
</Suspense>
</>
);
}
function App() {
return (
<ErrorBoundary>
<QueryClientProvider client={queryClient}>
<I18nextProvider i18n={i18n}>
<BrowserRouter
future={{
v7_startTransition: true,
v7_relativeSplatPath: true,
}}
>
<ThemeProvider>
<AuthProvider>
<SSEProvider>
<SubscriptionEventsProvider>
<EnterpriseProvider>
<AppContent />
</EnterpriseProvider>
</SubscriptionEventsProvider>
</SSEProvider>
</AuthProvider>
</ThemeProvider>
</BrowserRouter>
<ReactQueryDevtools initialIsOpen={false} />
</I18nextProvider>
</QueryClientProvider>
</ErrorBoundary>
);
}
export default App;

View File

@@ -0,0 +1,597 @@
/**
* Core HTTP client for React Query integration
*
* Architecture:
* - Axios: HTTP client for making requests
* - This Client: Handles auth tokens, tenant context, and error formatting
* - Services: Business logic that uses this client
* - React Query Hooks: Data fetching layer that uses services
*
* React Query doesn't replace HTTP clients - it manages data fetching/caching/sync
*/
import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse, AxiosError } from 'axios';
import { getApiUrl } from '../../config/runtime';
export interface ApiError {
message: string;
status?: number;
code?: string;
details?: any;
}
export interface SubscriptionError {
error: string;
message: string;
code: string;
details: {
required_feature: string;
required_level: string;
current_plan: string;
upgrade_url: string;
};
}
// Subscription error event emitter
class SubscriptionErrorEmitter extends EventTarget {
emitSubscriptionError(error: SubscriptionError) {
this.dispatchEvent(new CustomEvent('subscriptionError', { detail: error }));
}
}
export const subscriptionErrorEmitter = new SubscriptionErrorEmitter();
class ApiClient {
private client: AxiosInstance;
private baseURL: string;
private authToken: string | null = null;
private tenantId: string | null = null;
private demoSessionId: string | null = null;
private refreshToken: string | null = null;
private isRefreshing: boolean = false;
private refreshAttempts: number = 0;
private maxRefreshAttempts: number = 3;
private lastRefreshAttempt: number = 0;
private failedQueue: Array<{
resolve: (value?: any) => void;
reject: (error?: any) => void;
config: AxiosRequestConfig;
}> = [];
constructor(baseURL: string = getApiUrl() + '/v1') {
this.baseURL = baseURL;
this.client = axios.create({
baseURL: this.baseURL,
timeout: 30000,
headers: {
'Content-Type': 'application/json',
},
});
this.setupInterceptors();
}
private setupInterceptors() {
// Request interceptor to add auth headers
this.client.interceptors.request.use(
(config) => {
// Public endpoints that don't require authentication
const publicEndpoints = [
'/demo/accounts',
'/demo/session/create',
];
// Endpoints that require authentication but not a tenant ID (user-level endpoints)
const noTenantEndpoints = [
'/auth/users/', // User profile endpoints - user-level, no tenant context
'/auth/register', // Registration
'/auth/login', // Login
'/geocoding', // Geocoding/address search - utility service, no tenant context
'/tenants/register', // Tenant registration - creating new tenant, no existing tenant context
];
// Additional public endpoints that don't require authentication at all (including registration)
const publicAuthEndpoints = [
'/auth/start-registration', // Registration step 1 - SetupIntent creation
'/auth/complete-registration', // Registration step 2 - Completion after 3DS
'/auth/verify-email', // Email verification
];
const isPublicEndpoint = publicEndpoints.some(endpoint =>
config.url?.includes(endpoint)
);
const isPublicAuthEndpoint = publicAuthEndpoints.some(endpoint =>
config.url?.includes(endpoint)
);
const isNoTenantEndpoint = noTenantEndpoints.some(endpoint =>
config.url?.includes(endpoint)
);
// Check demo session ID from memory OR localStorage
const demoSessionId = this.demoSessionId || localStorage.getItem('demo_session_id');
const isDemoMode = !!demoSessionId;
// Only add auth token for non-public endpoints
if (this.authToken && !isPublicEndpoint && !isPublicAuthEndpoint) {
config.headers.Authorization = `Bearer ${this.authToken}`;
console.log('🔑 [API Client] Adding Authorization header for:', config.url);
} else if (!isPublicEndpoint && !isPublicAuthEndpoint && !isDemoMode) {
// Only warn if NOT in demo mode - demo mode uses X-Demo-Session-Id header instead
console.warn('⚠️ [API Client] No auth token available for:', config.url, 'authToken:', this.authToken ? 'exists' : 'missing');
}
// Add tenant ID only for endpoints that require it
if (this.tenantId && !isPublicEndpoint && !isPublicAuthEndpoint && !isNoTenantEndpoint) {
config.headers['X-Tenant-ID'] = this.tenantId;
console.log('🔍 [API Client] Adding X-Tenant-ID header:', this.tenantId, 'for URL:', config.url);
} else if (!isPublicEndpoint && !isPublicAuthEndpoint && !isNoTenantEndpoint) {
console.warn('⚠️ [API Client] No tenant ID set for endpoint:', config.url);
}
// Add demo session ID header if in demo mode
if (demoSessionId) {
config.headers['X-Demo-Session-Id'] = demoSessionId;
console.log('🔍 [API Client] Adding X-Demo-Session-Id header:', demoSessionId);
}
return config;
},
(error) => {
return Promise.reject(this.handleError(error));
}
);
// Response interceptor for error handling and automatic token refresh
this.client.interceptors.response.use(
(response) => {
// Enhanced logging for token refresh header detection
const refreshSuggested = response.headers['x-token-refresh-suggested'];
if (refreshSuggested) {
console.log('🔍 TOKEN REFRESH HEADER DETECTED:', {
url: response.config?.url,
method: response.config?.method,
status: response.status,
refreshSuggested,
hasRefreshToken: !!this.refreshToken,
currentTokenLength: this.authToken?.length || 0
});
}
// Check if server suggests token refresh
if (refreshSuggested === 'true' && this.refreshToken) {
console.log('🔄 Server suggests token refresh - refreshing proactively');
this.proactiveTokenRefresh();
}
return response;
},
async (error) => {
const originalRequest = error.config;
// Check if error is 401 and we have a refresh token
if (error.response?.status === 401 && this.refreshToken && !originalRequest._retry) {
// Check if we've exceeded max refresh attempts in a short time
const now = Date.now();
if (this.refreshAttempts >= this.maxRefreshAttempts && (now - this.lastRefreshAttempt) < 30000) {
console.log('Max refresh attempts exceeded, logging out');
await this.handleAuthFailure();
return Promise.reject(this.handleError(error));
}
if (this.isRefreshing) {
// If already refreshing, queue this request
return new Promise((resolve, reject) => {
this.failedQueue.push({ resolve, reject, config: originalRequest });
});
}
originalRequest._retry = true;
this.isRefreshing = true;
this.refreshAttempts++;
this.lastRefreshAttempt = now;
try {
console.log(`Attempting token refresh (attempt ${this.refreshAttempts})...`);
// Attempt to refresh the token
const response = await this.client.post('/auth/refresh', {
refresh_token: this.refreshToken
});
const { access_token, refresh_token } = response.data;
console.log('Token refresh successful');
// Reset refresh attempts on success
this.refreshAttempts = 0;
// Update tokens
this.setAuthToken(access_token);
if (refresh_token) {
this.setRefreshToken(refresh_token);
}
// Update auth store if available
await this.updateAuthStore(access_token, refresh_token);
// Process failed queue
this.processQueue(null, access_token);
// Retry original request with new token
originalRequest.headers.Authorization = `Bearer ${access_token}`;
return this.client(originalRequest);
} catch (refreshError) {
console.error(`Token refresh failed (attempt ${this.refreshAttempts}):`, refreshError);
// Refresh failed, clear tokens and redirect to login
this.processQueue(refreshError, null);
await this.handleAuthFailure();
return Promise.reject(this.handleError(refreshError as AxiosError));
} finally {
this.isRefreshing = false;
}
}
return Promise.reject(this.handleError(error));
}
);
}
private handleError(error: AxiosError): ApiError {
if (error.response) {
// Server responded with error status
const { status, data } = error.response;
// Check for subscription errors
if (status === 403 && (data as any)?.code === 'SUBSCRIPTION_UPGRADE_REQUIRED') {
const subscriptionError = data as SubscriptionError;
subscriptionErrorEmitter.emitSubscriptionError(subscriptionError);
}
return {
message: (data as any)?.detail || (data as any)?.message || `Request failed with status ${status}`,
status,
code: (data as any)?.code,
details: data,
};
} else if (error.request) {
// Network error
return {
message: 'Network error - please check your connection',
status: 0,
};
} else {
// Other error
return {
message: error.message || 'Unknown error occurred',
};
}
}
private processQueue(error: any, token: string | null = null) {
this.failedQueue.forEach(({ resolve, reject, config }) => {
if (error) {
reject(error);
} else {
if (token) {
config.headers = config.headers || {};
config.headers.Authorization = `Bearer ${token}`;
}
resolve(this.client(config));
}
});
this.failedQueue = [];
}
private async updateAuthStore(accessToken: string, refreshToken?: string) {
try {
// Dynamically import to avoid circular dependency
const { useAuthStore } = await import('../../stores/auth.store');
const { getSubscriptionFromJWT, getTenantAccessFromJWT, getPrimaryTenantIdFromJWT } = await import('../../utils/jwt');
const setState = useAuthStore.setState;
// CRITICAL: Extract fresh subscription data from new JWT
const jwtSubscription = getSubscriptionFromJWT(accessToken);
const jwtTenantAccess = getTenantAccessFromJWT(accessToken);
const primaryTenantId = getPrimaryTenantIdFromJWT(accessToken);
// Update the store with new tokens AND subscription data
setState(state => ({
...state,
token: accessToken,
refreshToken: refreshToken || state.refreshToken,
// IMPORTANT: Update subscription from fresh JWT
jwtSubscription,
jwtTenantAccess,
primaryTenantId,
}));
console.log('✅ Auth store updated with new token and subscription:', jwtSubscription?.tier);
// Broadcast change to all Zustand subscribers
console.log('📢 Zustand state updated - all useJWTSubscription() hooks will re-render');
} catch (error) {
console.warn('Failed to update auth store:', error);
}
}
private async proactiveTokenRefresh() {
// Avoid multiple simultaneous proactive refreshes
if (this.isRefreshing) {
return;
}
try {
this.isRefreshing = true;
console.log('🔄 Proactively refreshing token...');
const response = await this.client.post('/auth/refresh', {
refresh_token: this.refreshToken
});
const { access_token, refresh_token } = response.data;
// Update tokens
this.setAuthToken(access_token);
if (refresh_token) {
this.setRefreshToken(refresh_token);
}
// Update auth store
await this.updateAuthStore(access_token, refresh_token);
console.log('✅ Proactive token refresh successful');
} catch (error) {
console.warn('⚠️ Proactive token refresh failed:', error);
// Don't handle as auth failure here - let the next 401 handle it
} finally {
this.isRefreshing = false;
}
}
private async handleAuthFailure() {
try {
// Clear tokens
this.setAuthToken(null);
this.setRefreshToken(null);
// Dynamically import to avoid circular dependency
const { useAuthStore } = await import('../../stores/auth.store');
const store = useAuthStore.getState();
// Logout user
store.logout();
// Redirect to login if not already there
if (typeof window !== 'undefined' && !window.location.pathname.includes('/login')) {
window.location.href = '/login';
}
} catch (error) {
console.warn('Failed to handle auth failure:', error);
}
}
// Configuration methods
setAuthToken(token: string | null) {
console.log('🔧 [API Client] setAuthToken called:', token ? `${token.substring(0, 20)}...` : 'null');
this.authToken = token;
console.log('✅ [API Client] authToken is now:', this.authToken ? 'set' : 'null');
}
setRefreshToken(token: string | null) {
this.refreshToken = token;
}
setTenantId(tenantId: string | null) {
console.log('🔧 [API Client] setTenantId called with:', tenantId);
console.log('🔧 [API Client] Previous tenantId was:', this.tenantId);
console.trace('📍 [API Client] setTenantId call stack:');
this.tenantId = tenantId;
console.log('✅ [API Client] tenantId is now:', this.tenantId);
}
setDemoSessionId(sessionId: string | null) {
this.demoSessionId = sessionId;
if (sessionId) {
localStorage.setItem('demo_session_id', sessionId);
} else {
localStorage.removeItem('demo_session_id');
}
}
getDemoSessionId(): string | null {
return this.demoSessionId || localStorage.getItem('demo_session_id');
}
getAuthToken(): string | null {
return this.authToken;
}
getRefreshToken(): string | null {
return this.refreshToken;
}
getTenantId(): string | null {
return this.tenantId;
}
// Token synchronization methods for WebSocket connections
getCurrentValidToken(): string | null {
return this.authToken;
}
async ensureValidToken(): Promise<string | null> {
const originalToken = this.authToken;
const originalTokenShort = originalToken ? `${originalToken.slice(0, 20)}...${originalToken.slice(-10)}` : 'null';
console.log('🔍 ensureValidToken() called:', {
hasToken: !!this.authToken,
tokenPreview: originalTokenShort,
isRefreshing: this.isRefreshing,
hasRefreshToken: !!this.refreshToken
});
// If we have a valid token, return it
if (this.authToken && !this.isTokenNearExpiry(this.authToken)) {
const expiryInfo = this.getTokenExpiryInfo(this.authToken);
console.log('✅ Token is valid, returning current token:', {
tokenPreview: originalTokenShort,
expiryInfo
});
return this.authToken;
}
// If token is near expiry or expired, try to refresh
if (this.refreshToken && !this.isRefreshing) {
console.log('🔄 Token needs refresh, attempting proactive refresh:', {
reason: this.authToken ? 'near expiry' : 'no token',
expiryInfo: this.authToken ? this.getTokenExpiryInfo(this.authToken) : 'N/A'
});
try {
await this.proactiveTokenRefresh();
const newTokenShort = this.authToken ? `${this.authToken.slice(0, 20)}...${this.authToken.slice(-10)}` : 'null';
const tokenChanged = originalToken !== this.authToken;
console.log('✅ Token refresh completed:', {
tokenChanged,
oldTokenPreview: originalTokenShort,
newTokenPreview: newTokenShort,
newExpiryInfo: this.authToken ? this.getTokenExpiryInfo(this.authToken) : 'N/A'
});
return this.authToken;
} catch (error) {
console.warn('❌ Failed to refresh token in ensureValidToken:', error);
return null;
}
}
console.log('⚠️ Returning current token without refresh:', {
reason: this.isRefreshing ? 'already refreshing' : 'no refresh token',
tokenPreview: originalTokenShort
});
return this.authToken;
}
private getTokenExpiryInfo(token: string): any {
try {
const payload = JSON.parse(atob(token.split('.')[1]));
const exp = payload.exp;
const iat = payload.iat;
if (!exp) return { error: 'No expiry in token' };
const now = Math.floor(Date.now() / 1000);
const timeUntilExpiry = exp - now;
const tokenLifetime = exp - iat;
return {
issuedAt: new Date(iat * 1000).toISOString(),
expiresAt: new Date(exp * 1000).toISOString(),
lifetimeMinutes: Math.floor(tokenLifetime / 60),
secondsUntilExpiry: timeUntilExpiry,
minutesUntilExpiry: Math.floor(timeUntilExpiry / 60),
isNearExpiry: timeUntilExpiry < 300,
isExpired: timeUntilExpiry <= 0
};
} catch (error) {
return { error: 'Failed to parse token', details: error };
}
}
private isTokenNearExpiry(token: string): boolean {
try {
const payload = JSON.parse(atob(token.split('.')[1]));
const exp = payload.exp;
if (!exp) return false;
const now = Math.floor(Date.now() / 1000);
const timeUntilExpiry = exp - now;
// Consider token near expiry if less than 5 minutes remaining
const isNear = timeUntilExpiry < 300;
if (isNear) {
console.log('⏰ Token is near expiry:', {
secondsUntilExpiry: timeUntilExpiry,
minutesUntilExpiry: Math.floor(timeUntilExpiry / 60),
expiresAt: new Date(exp * 1000).toISOString()
});
}
return isNear;
} catch (error) {
console.warn('Failed to parse token for expiry check:', error);
return true; // Assume expired if we can't parse
}
}
// HTTP Methods - Return direct data for React Query
async get<T = any>(url: string, config?: AxiosRequestConfig): Promise<T> {
const response: AxiosResponse<T> = await this.client.get(url, config);
return response.data;
}
async post<T = any, D = any>(
url: string,
data?: D,
config?: AxiosRequestConfig
): Promise<T> {
const response: AxiosResponse<T> = await this.client.post(url, data, config);
return response.data;
}
async put<T = any, D = any>(
url: string,
data?: D,
config?: AxiosRequestConfig
): Promise<T> {
const response: AxiosResponse<T> = await this.client.put(url, data, config);
return response.data;
}
async patch<T = any, D = any>(
url: string,
data?: D,
config?: AxiosRequestConfig
): Promise<T> {
const response: AxiosResponse<T> = await this.client.patch(url, data, config);
return response.data;
}
async delete<T = any>(url: string, config?: AxiosRequestConfig): Promise<T> {
const response: AxiosResponse<T> = await this.client.delete(url, config);
return response.data;
}
// File upload helper
async uploadFile<T = any>(
url: string,
file: File | FormData,
config?: AxiosRequestConfig
): Promise<T> {
const formData = file instanceof FormData ? file : new FormData();
if (file instanceof File) {
formData.append('file', file);
}
return this.post<T>(url, formData, {
...config,
headers: {
...config?.headers,
'Content-Type': 'multipart/form-data',
},
});
}
// Raw axios instance for advanced usage
getAxiosInstance(): AxiosInstance {
return this.client;
}
}
// Create and export singleton instance
export const apiClient = new ApiClient();
export default apiClient;

View File

@@ -0,0 +1,2 @@
export { apiClient, default } from './apiClient';
export type { ApiError } from './apiClient';

View File

@@ -0,0 +1,303 @@
/**
* React Hooks for AI Insights
*
* Provides React Query hooks for AI Insights API integration.
*
* Usage:
* ```tsx
* const { data: insights, isLoading } = useAIInsights(tenantId, { priority: 'high' });
* const { data: stats } = useAIInsightStats(tenantId);
* const applyMutation = useApplyInsight();
* ```
*
* Last Updated: 2025-11-03
* Status: ✅ Complete - React Query Integration
*/
import { useQuery, useMutation, useQueryClient, UseQueryOptions, UseMutationOptions } from '@tanstack/react-query';
import { useState } from 'react';
import {
aiInsightsService,
AIInsight,
AIInsightFilters,
AIInsightListResponse,
AIInsightStatsResponse,
FeedbackRequest,
OrchestrationReadyInsightsRequest,
OrchestrationReadyInsightsResponse,
} from '../services/aiInsights';
// Query Keys
export const aiInsightsKeys = {
all: ['aiInsights'] as const,
lists: () => [...aiInsightsKeys.all, 'list'] as const,
list: (tenantId: string, filters?: AIInsightFilters) => [...aiInsightsKeys.lists(), tenantId, filters] as const,
details: () => [...aiInsightsKeys.all, 'detail'] as const,
detail: (tenantId: string, insightId: string) => [...aiInsightsKeys.details(), tenantId, insightId] as const,
stats: (tenantId: string, filters?: any) => [...aiInsightsKeys.all, 'stats', tenantId, filters] as const,
orchestration: (tenantId: string, targetDate: string) => [...aiInsightsKeys.all, 'orchestration', tenantId, targetDate] as const,
dashboard: (tenantId: string) => [...aiInsightsKeys.all, 'dashboard', tenantId] as const,
};
/**
* Hook to get AI insights with filters
*/
export function useAIInsights(
tenantId: string,
filters?: AIInsightFilters,
options?: Omit<UseQueryOptions<AIInsightListResponse>, 'queryKey' | 'queryFn'>
) {
return useQuery({
queryKey: aiInsightsKeys.list(tenantId, filters),
queryFn: () => aiInsightsService.getInsights(tenantId, filters),
staleTime: 1000 * 60 * 2, // 2 minutes
...options,
});
}
/**
* Hook to get a single AI insight
*/
export function useAIInsight(
tenantId: string,
insightId: string,
options?: Omit<UseQueryOptions<AIInsight>, 'queryKey' | 'queryFn'>
) {
return useQuery({
queryKey: aiInsightsKeys.detail(tenantId, insightId),
queryFn: () => aiInsightsService.getInsight(tenantId, insightId),
enabled: !!insightId,
staleTime: 1000 * 60 * 5, // 5 minutes
...options,
});
}
/**
* Hook to get AI insight statistics
*/
export function useAIInsightStats(
tenantId: string,
filters?: { start_date?: string; end_date?: string },
options?: Omit<UseQueryOptions<AIInsightStatsResponse>, 'queryKey' | 'queryFn'>
) {
return useQuery({
queryKey: aiInsightsKeys.stats(tenantId, filters),
queryFn: () => aiInsightsService.getInsightStats(tenantId, filters),
staleTime: 1000 * 60 * 5, // 5 minutes
...options,
});
}
/**
* Hook to get orchestration-ready insights
*/
export function useOrchestrationReadyInsights(
tenantId: string,
request: OrchestrationReadyInsightsRequest,
options?: Omit<UseQueryOptions<OrchestrationReadyInsightsResponse>, 'queryKey' | 'queryFn'>
) {
return useQuery({
queryKey: aiInsightsKeys.orchestration(tenantId, request.target_date),
queryFn: () => aiInsightsService.getOrchestrationReadyInsights(tenantId, request),
enabled: !!request.target_date,
staleTime: 1000 * 60 * 10, // 10 minutes
...options,
});
}
/**
* Hook to get dashboard summary
*/
export function useAIInsightsDashboard(
tenantId: string,
options?: Omit<UseQueryOptions<any>, 'queryKey' | 'queryFn'>
) {
return useQuery({
queryKey: aiInsightsKeys.dashboard(tenantId),
queryFn: () => aiInsightsService.getDashboardSummary(tenantId),
staleTime: 1000 * 60 * 2, // 2 minutes
...options,
});
}
/**
* Hook to get high priority insights
*/
export function useHighPriorityInsights(
tenantId: string,
limit: number = 10,
options?: Omit<UseQueryOptions<AIInsight[]>, 'queryKey' | 'queryFn'>
) {
return useQuery({
queryKey: [...aiInsightsKeys.lists(), tenantId, 'highPriority', limit],
queryFn: () => aiInsightsService.getHighPriorityInsights(tenantId, limit),
staleTime: 1000 * 60 * 2, // 2 minutes
...options,
});
}
/**
* Hook to get actionable insights
*/
export function useActionableInsights(
tenantId: string,
limit: number = 20,
options?: Omit<UseQueryOptions<AIInsight[]>, 'queryKey' | 'queryFn'>
) {
return useQuery({
queryKey: [...aiInsightsKeys.lists(), tenantId, 'actionable', limit],
queryFn: () => aiInsightsService.getActionableInsights(tenantId, limit),
staleTime: 1000 * 60 * 2, // 2 minutes
...options,
});
}
/**
* Hook to get insights by category
*/
export function useInsightsByCategory(
tenantId: string,
category: string,
limit: number = 20,
options?: Omit<UseQueryOptions<AIInsight[]>, 'queryKey' | 'queryFn'>
) {
return useQuery({
queryKey: [...aiInsightsKeys.lists(), tenantId, 'category', category, limit],
queryFn: () => aiInsightsService.getInsightsByCategory(tenantId, category, limit),
enabled: !!category,
staleTime: 1000 * 60 * 2, // 2 minutes
...options,
});
}
/**
* Hook to search insights
*/
export function useSearchInsights(
tenantId: string,
query: string,
filters?: Partial<AIInsightFilters>,
options?: Omit<UseQueryOptions<AIInsight[]>, 'queryKey' | 'queryFn'>
) {
return useQuery({
queryKey: [...aiInsightsKeys.lists(), tenantId, 'search', query, filters],
queryFn: () => aiInsightsService.searchInsights(tenantId, query, filters),
enabled: query.length > 0,
staleTime: 1000 * 30, // 30 seconds
...options,
});
}
/**
* Mutation hook to apply an insight
*/
export function useApplyInsight(
options?: UseMutationOptions<AIInsight, Error, { tenantId: string; insightId: string }>
) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ tenantId, insightId }: { tenantId: string; insightId: string }) =>
aiInsightsService.applyInsight(tenantId, insightId),
onSuccess: (_, variables) => {
// Invalidate all insight queries for this tenant
queryClient.invalidateQueries({ queryKey: aiInsightsKeys.lists() });
queryClient.invalidateQueries({ queryKey: aiInsightsKeys.detail(variables.tenantId, variables.insightId) });
queryClient.invalidateQueries({ queryKey: aiInsightsKeys.stats(variables.tenantId) });
queryClient.invalidateQueries({ queryKey: aiInsightsKeys.dashboard(variables.tenantId) });
},
...options,
});
}
/**
* Mutation hook to dismiss an insight
*/
export function useDismissInsight(
options?: UseMutationOptions<void, Error, { tenantId: string; insightId: string }>
) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ tenantId, insightId }) =>
aiInsightsService.dismissInsight(tenantId, insightId),
onSuccess: (_, variables) => {
queryClient.invalidateQueries({ queryKey: aiInsightsKeys.lists() });
queryClient.invalidateQueries({ queryKey: aiInsightsKeys.detail(variables.tenantId, variables.insightId) });
queryClient.invalidateQueries({ queryKey: aiInsightsKeys.stats(variables.tenantId) });
queryClient.invalidateQueries({ queryKey: aiInsightsKeys.dashboard(variables.tenantId) });
},
...options,
});
}
/**
* Mutation hook to update insight status
*/
export function useUpdateInsightStatus(
options?: UseMutationOptions<AIInsight, Error, { tenantId: string; insightId: string; status: 'acknowledged' | 'in_progress' | 'applied' | 'expired' }>
) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ tenantId, insightId, status }) =>
aiInsightsService.updateInsightStatus(tenantId, insightId, status),
onSuccess: (_, variables) => {
queryClient.invalidateQueries({ queryKey: aiInsightsKeys.lists() });
queryClient.invalidateQueries({ queryKey: aiInsightsKeys.detail(variables.tenantId, variables.insightId) });
queryClient.invalidateQueries({ queryKey: aiInsightsKeys.stats(variables.tenantId) });
queryClient.invalidateQueries({ queryKey: aiInsightsKeys.dashboard(variables.tenantId) });
},
...options,
});
}
/**
* Mutation hook to record feedback for an insight
*/
export function useRecordFeedback(
options?: UseMutationOptions<any, Error, { tenantId: string; insightId: string; feedback: FeedbackRequest }>
) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ tenantId, insightId, feedback }) =>
aiInsightsService.recordFeedback(tenantId, insightId, feedback),
onSuccess: (_, variables) => {
queryClient.invalidateQueries({ queryKey: aiInsightsKeys.detail(variables.tenantId, variables.insightId) });
queryClient.invalidateQueries({ queryKey: aiInsightsKeys.stats(variables.tenantId) });
},
...options,
});
}
/**
* Utility hook to manage insight selection
*/
export function useInsightSelection() {
const [selectedInsights, setSelectedInsights] = useState<string[]>([]);
const toggleInsight = (insightId: string) => {
setSelectedInsights((prev) =>
prev.includes(insightId)
? prev.filter((id) => id !== insightId)
: [...prev, insightId]
);
};
const selectAll = (insightIds: string[]) => {
setSelectedInsights(insightIds);
};
const clearSelection = () => {
setSelectedInsights([]);
};
return {
selectedInsights,
toggleInsight,
selectAll,
clearSelection,
isSelected: (insightId: string) => selectedInsights.includes(insightId),
};
}

View File

@@ -0,0 +1,115 @@
/**
* Audit Logs React Query hooks
*
* Provides React Query hooks for fetching and managing audit logs
* across all microservices with caching and real-time updates.
*
* Last Updated: 2025-11-02
* Status: ✅ Complete
*/
import { useQuery, UseQueryOptions } from '@tanstack/react-query';
import { auditLogsService } from '../services/auditLogs';
import {
AuditLogResponse,
AuditLogFilters,
AuditLogListResponse,
AuditLogStatsResponse,
AggregatedAuditLog,
AuditLogServiceName,
} from '../types/auditLogs';
import { ApiError } from '../client';
// Query Keys
export const auditLogKeys = {
all: ['audit-logs'] as const,
lists: () => [...auditLogKeys.all, 'list'] as const,
list: (tenantId: string, filters?: AuditLogFilters) =>
[...auditLogKeys.lists(), tenantId, filters] as const,
serviceList: (tenantId: string, service: AuditLogServiceName, filters?: AuditLogFilters) =>
[...auditLogKeys.lists(), 'service', tenantId, service, filters] as const,
stats: () => [...auditLogKeys.all, 'stats'] as const,
stat: (tenantId: string, filters?: { start_date?: string; end_date?: string }) =>
[...auditLogKeys.stats(), tenantId, filters] as const,
serviceStat: (
tenantId: string,
service: AuditLogServiceName,
filters?: { start_date?: string; end_date?: string }
) => [...auditLogKeys.stats(), 'service', tenantId, service, filters] as const,
} as const;
/**
* Hook to fetch audit logs from a single service
*/
export function useServiceAuditLogs(
tenantId: string,
serviceName: AuditLogServiceName,
filters?: AuditLogFilters,
options?: Omit<UseQueryOptions<AuditLogListResponse, ApiError>, 'queryKey' | 'queryFn'>
) {
return useQuery<AuditLogListResponse, ApiError>({
queryKey: auditLogKeys.serviceList(tenantId, serviceName, filters),
queryFn: () => auditLogsService.getServiceAuditLogs(tenantId, serviceName, filters),
enabled: !!tenantId,
staleTime: 30000, // 30 seconds
...options,
});
}
/**
* Hook to fetch aggregated audit logs from ALL services
*/
export function useAllAuditLogs(
tenantId: string,
filters?: AuditLogFilters,
options?: Omit<UseQueryOptions<AggregatedAuditLog[], ApiError>, 'queryKey' | 'queryFn'>
) {
return useQuery<AggregatedAuditLog[], ApiError>({
queryKey: auditLogKeys.list(tenantId, filters),
queryFn: () => auditLogsService.getAllAuditLogs(tenantId, filters),
enabled: !!tenantId,
staleTime: 30000, // 30 seconds
...options,
});
}
/**
* Hook to fetch audit log statistics from a single service
*/
export function useServiceAuditLogStats(
tenantId: string,
serviceName: AuditLogServiceName,
filters?: {
start_date?: string;
end_date?: string;
},
options?: Omit<UseQueryOptions<AuditLogStatsResponse, ApiError>, 'queryKey' | 'queryFn'>
) {
return useQuery<AuditLogStatsResponse, ApiError>({
queryKey: auditLogKeys.serviceStat(tenantId, serviceName, filters),
queryFn: () => auditLogsService.getServiceAuditLogStats(tenantId, serviceName, filters),
enabled: !!tenantId,
staleTime: 60000, // 1 minute
...options,
});
}
/**
* Hook to fetch aggregated audit log statistics from ALL services
*/
export function useAllAuditLogStats(
tenantId: string,
filters?: {
start_date?: string;
end_date?: string;
},
options?: Omit<UseQueryOptions<AuditLogStatsResponse, ApiError>, 'queryKey' | 'queryFn'>
) {
return useQuery<AuditLogStatsResponse, ApiError>({
queryKey: auditLogKeys.stat(tenantId, filters),
queryFn: () => auditLogsService.getAllAuditLogStats(tenantId, filters),
enabled: !!tenantId,
staleTime: 60000, // 1 minute
...options,
});
}

View File

@@ -0,0 +1,215 @@
/**
* Auth React Query hooks
* Updated for atomic registration architecture with 3DS support
*/
import { useMutation, useQuery, useQueryClient, UseQueryOptions, UseMutationOptions } from '@tanstack/react-query';
import { authService } from '../services/auth';
import {
UserRegistration,
UserLogin,
TokenResponse,
PasswordChange,
PasswordReset,
UserResponse,
UserUpdate,
TokenVerification,
RegistrationStartResponse,
RegistrationCompletionResponse,
RegistrationVerification,
} from '../types/auth';
import { ApiError } from '../client';
import { useAuthStore } from '../../stores/auth.store';
// Query Keys
export const authKeys = {
all: ['auth'] as const,
profile: () => [...authKeys.all, 'profile'] as const,
health: () => [...authKeys.all, 'health'] as const,
verify: (token?: string) => [...authKeys.all, 'verify', token] as const,
} as const;
// Queries
export const useAuthProfile = (
options?: Omit<UseQueryOptions<UserResponse, ApiError>, 'queryKey' | 'queryFn'>
) => {
return useQuery<UserResponse, ApiError>({
queryKey: authKeys.profile(),
queryFn: () => authService.getProfile(),
staleTime: 5 * 60 * 1000, // 5 minutes
...options,
});
};
export const useAuthHealth = (
options?: Omit<UseQueryOptions<{ status: string; service: string }, ApiError>, 'queryKey' | 'queryFn'>
) => {
return useQuery<{ status: string; service: string }, ApiError>({
queryKey: authKeys.health(),
queryFn: () => authService.healthCheck(),
staleTime: 30 * 1000, // 30 seconds
...options,
});
};
export const useVerifyToken = (
token?: string,
options?: Omit<UseQueryOptions<TokenVerification, ApiError>, 'queryKey' | 'queryFn'>
) => {
return useQuery<TokenVerification, ApiError>({
queryKey: authKeys.verify(token),
queryFn: () => authService.verifyToken(token),
enabled: !!token,
staleTime: 5 * 60 * 1000, // 5 minutes
...options,
});
};
// Mutations
export const useStartRegistration = (
options?: UseMutationOptions<RegistrationStartResponse, ApiError, UserRegistration>
) => {
const queryClient = useQueryClient();
return useMutation<RegistrationStartResponse, ApiError, UserRegistration>({
mutationFn: (userData: UserRegistration) => authService.startRegistration(userData),
onSuccess: (data) => {
// If no 3DS required, update profile query with new user data
if (data.user) {
queryClient.setQueryData(authKeys.profile(), data.user);
}
},
...options,
});
};
/**
* Hook for completing registration after 3DS verification
* This is the second step in the atomic registration flow
*/
export const useCompleteRegistration = (
options?: UseMutationOptions<RegistrationCompletionResponse, ApiError, RegistrationVerification>
) => {
const queryClient = useQueryClient();
return useMutation<RegistrationCompletionResponse, ApiError, RegistrationVerification>({
mutationFn: (verificationData: RegistrationVerification) => authService.completeRegistration(verificationData),
onSuccess: (data) => {
// Update profile query with new user data
if (data.user) {
queryClient.setQueryData(authKeys.profile(), data.user);
}
// Invalidate all queries to refresh data
queryClient.invalidateQueries({ queryKey: ['auth'] });
},
...options,
});
};
export const useLogin = (
options?: UseMutationOptions<TokenResponse, ApiError, UserLogin>
) => {
const queryClient = useQueryClient();
return useMutation<TokenResponse, ApiError, UserLogin>({
mutationFn: (loginData: UserLogin) => authService.login(loginData),
onSuccess: (data) => {
// Update profile query with new user data
if (data.user) {
queryClient.setQueryData(authKeys.profile(), data.user);
}
// Invalidate all queries to refresh data
queryClient.invalidateQueries({ queryKey: ['auth'] });
},
...options,
});
};
export const useRefreshToken = (
options?: UseMutationOptions<TokenResponse, ApiError, string>
) => {
return useMutation<TokenResponse, ApiError, string>({
mutationFn: (refreshToken: string) => authService.refreshToken(refreshToken),
...options,
});
};
export const useLogout = (
options?: UseMutationOptions<{ message: string }, ApiError, string>
) => {
const queryClient = useQueryClient();
return useMutation<{ message: string }, ApiError, string>({
mutationFn: (refreshToken: string) => authService.logout(refreshToken),
onSuccess: () => {
// Clear all queries on logout
queryClient.clear();
},
...options,
});
};
export const useChangePassword = (
options?: UseMutationOptions<{ message: string }, ApiError, PasswordChange>
) => {
return useMutation<{ message: string }, ApiError, PasswordChange>({
mutationFn: (passwordData: PasswordChange) => authService.changePassword(passwordData),
...options,
});
};
export const useRequestPasswordReset = (
options?: UseMutationOptions<{ message: string }, ApiError, string>
) => {
return useMutation<{ message: string }, ApiError, string>({
mutationFn: (email: string) => authService.requestPasswordReset(email),
...options,
});
};
export const useResetPasswordWithToken = (
options?: UseMutationOptions<{ message: string }, ApiError, { token: string; newPassword: string }>
) => {
return useMutation<{ message: string }, ApiError, { token: string; newPassword: string }>({
mutationFn: ({ token, newPassword }) => authService.resetPasswordWithToken(token, newPassword),
...options,
});
};
export const useUpdateProfile = (
options?: UseMutationOptions<UserResponse, ApiError, UserUpdate>
) => {
const queryClient = useQueryClient();
return useMutation<UserResponse, ApiError, UserUpdate>({
mutationFn: (updateData: UserUpdate) => authService.updateProfile(updateData),
onSuccess: (data) => {
// Update the profile cache
queryClient.setQueryData(authKeys.profile(), data);
// Update the auth store user to maintain consistency
const authStore = useAuthStore.getState();
if (authStore.user) {
authStore.updateUser(data as any);
}
},
...options,
});
};
export const useVerifyEmail = (
options?: UseMutationOptions<{ message: string }, ApiError, { userId: string; verificationToken: string }>
) => {
const queryClient = useQueryClient();
return useMutation<{ message: string }, ApiError, { userId: string; verificationToken: string }>({
mutationFn: ({ userId, verificationToken }) =>
authService.verifyEmail(userId, verificationToken),
onSuccess: () => {
// Invalidate profile to get updated verification status
queryClient.invalidateQueries({ queryKey: authKeys.profile() });
},
...options,
});
};

View File

@@ -0,0 +1,84 @@
/**
* Enterprise Dashboard Hooks - Clean Implementation
*
* Phase 3 Complete: All dashboard hooks call services directly.
* Distribution and forecast still use orchestrator (Phase 4 migration).
*/
export {
useNetworkSummary,
useChildrenPerformance,
useChildTenants,
useChildSales,
useChildInventory,
useChildProduction,
} from './useEnterpriseDashboard';
export type {
NetworkSummary,
PerformanceRankings as ChildPerformance,
ChildTenant,
SalesSummary,
InventorySummary,
ProductionSummary,
} from './useEnterpriseDashboard';
// Distribution and forecast hooks (Phase 4 - To be migrated)
import { useQuery, UseQueryOptions } from '@tanstack/react-query';
import { ApiError, apiClient } from '../client';
export interface DistributionOverview {
route_sequences: any[];
status_counts: {
pending: number;
in_transit: number;
delivered: number;
failed: number;
[key: string]: number;
};
}
export interface ForecastSummary {
aggregated_forecasts: Record<string, any>;
days_forecast: number;
last_updated: string;
}
export const useDistributionOverview = (
tenantId: string,
targetDate?: string,
options?: Omit<UseQueryOptions<DistributionOverview, ApiError>, 'queryKey' | 'queryFn'>
) => {
return useQuery<DistributionOverview, ApiError>({
queryKey: ['enterprise', 'distribution-overview', tenantId, targetDate],
queryFn: async () => {
const params = new URLSearchParams();
if (targetDate) params.append('target_date', targetDate);
const queryString = params.toString();
return apiClient.get<DistributionOverview>(
`/tenants/${tenantId}/enterprise/distribution-overview${queryString ? `?${queryString}` : ''}`
);
},
enabled: !!tenantId,
staleTime: 30000,
...options,
});
};
export const useForecastSummary = (
tenantId: string,
daysAhead: number = 7,
options?: Omit<UseQueryOptions<ForecastSummary, ApiError>, 'queryKey' | 'queryFn'>
) => {
return useQuery<ForecastSummary, ApiError>({
queryKey: ['enterprise', 'forecast-summary', tenantId, daysAhead],
queryFn: async () => {
return apiClient.get<ForecastSummary>(
`/tenants/${tenantId}/enterprise/forecast-summary?days_ahead=${daysAhead}`
);
},
enabled: !!tenantId,
staleTime: 120000,
...options,
});
};

View File

@@ -0,0 +1,184 @@
// frontend/src/api/hooks/equipment.ts
/**
* React hooks for Equipment API integration
*/
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { showToast } from '../../utils/toast';
import { equipmentService } from '../services/equipment';
import type { Equipment, EquipmentDeletionSummary } from '../types/equipment';
// Query Keys
export const equipmentKeys = {
all: ['equipment'] as const,
lists: () => [...equipmentKeys.all, 'list'] as const,
list: (tenantId: string, filters?: Record<string, any>) =>
[...equipmentKeys.lists(), tenantId, filters] as const,
details: () => [...equipmentKeys.all, 'detail'] as const,
detail: (tenantId: string, equipmentId: string) =>
[...equipmentKeys.details(), tenantId, equipmentId] as const,
};
/**
* Hook to fetch equipment list
*/
export function useEquipment(
tenantId: string,
filters?: {
status?: string;
type?: string;
is_active?: boolean;
},
options?: { enabled?: boolean }
) {
return useQuery({
queryKey: equipmentKeys.list(tenantId, filters),
queryFn: () => equipmentService.getEquipment(tenantId, filters),
enabled: !!tenantId && (options?.enabled ?? true),
staleTime: 5 * 60 * 1000, // 5 minutes
});
}
/**
* Hook to fetch a specific equipment item
*/
export function useEquipmentById(
tenantId: string,
equipmentId: string,
options?: { enabled?: boolean }
) {
return useQuery({
queryKey: equipmentKeys.detail(tenantId, equipmentId),
queryFn: () => equipmentService.getEquipmentById(tenantId, equipmentId),
enabled: !!tenantId && !!equipmentId && (options?.enabled ?? true),
staleTime: 10 * 60 * 1000, // 10 minutes
});
}
/**
* Hook to create equipment
*/
export function useCreateEquipment(tenantId: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (equipmentData: Equipment) =>
equipmentService.createEquipment(tenantId, equipmentData),
onSuccess: (newEquipment) => {
// Invalidate and refetch equipment lists
queryClient.invalidateQueries({ queryKey: equipmentKeys.lists() });
// Add to cache
queryClient.setQueryData(
equipmentKeys.detail(tenantId, newEquipment.id),
newEquipment
);
showToast.success('Equipment created successfully');
},
onError: (error: any) => {
console.error('Error creating equipment:', error);
showToast.error(error.response?.data?.detail || 'Error creating equipment');
},
});
}
/**
* Hook to update equipment
*/
export function useUpdateEquipment(tenantId: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ equipmentId, equipmentData }: {
equipmentId: string;
equipmentData: Partial<Equipment>;
}) => equipmentService.updateEquipment(tenantId, equipmentId, equipmentData),
onSuccess: (updatedEquipment, { equipmentId }) => {
// Update cached data
queryClient.setQueryData(
equipmentKeys.detail(tenantId, equipmentId),
updatedEquipment
);
// Invalidate lists to refresh
queryClient.invalidateQueries({ queryKey: equipmentKeys.lists() });
showToast.success('Equipment updated successfully');
},
onError: (error: any) => {
console.error('Error updating equipment:', error);
showToast.error(error.response?.data?.detail || 'Error updating equipment');
},
});
}
/**
* Hook to delete equipment (soft delete)
*/
export function useDeleteEquipment(tenantId: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (equipmentId: string) =>
equipmentService.deleteEquipment(tenantId, equipmentId),
onSuccess: (_, equipmentId) => {
// Remove from cache
queryClient.removeQueries({
queryKey: equipmentKeys.detail(tenantId, equipmentId)
});
// Invalidate lists to refresh
queryClient.invalidateQueries({ queryKey: equipmentKeys.lists() });
showToast.success('Equipment deleted successfully');
},
onError: (error: any) => {
console.error('Error deleting equipment:', error);
showToast.error(error.response?.data?.detail || 'Error deleting equipment');
},
});
}
/**
* Hook to hard delete equipment (permanent deletion)
*/
export function useHardDeleteEquipment(tenantId: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (equipmentId: string) =>
equipmentService.hardDeleteEquipment(tenantId, equipmentId),
onSuccess: (_, equipmentId) => {
// Remove from cache
queryClient.removeQueries({
queryKey: equipmentKeys.detail(tenantId, equipmentId)
});
// Invalidate lists to refresh
queryClient.invalidateQueries({ queryKey: equipmentKeys.lists() });
showToast.success('Equipment permanently deleted');
},
onError: (error: any) => {
console.error('Error hard deleting equipment:', error);
showToast.error(error.response?.data?.detail || 'Error permanently deleting equipment');
},
});
}
/**
* Hook to get equipment deletion summary
*/
export function useEquipmentDeletionSummary(
tenantId: string,
equipmentId: string,
options?: { enabled?: boolean }
) {
return useQuery({
queryKey: [...equipmentKeys.detail(tenantId, equipmentId), 'deletion-summary'],
queryFn: () => equipmentService.getEquipmentDeletionSummary(tenantId, equipmentId),
enabled: !!tenantId && !!equipmentId && (options?.enabled ?? true),
staleTime: 0, // Always fetch fresh data for dependency checks
});
}

View File

@@ -0,0 +1,316 @@
/**
* Forecasting React Query hooks
*/
import {
useMutation,
useQuery,
useQueryClient,
UseQueryOptions,
UseMutationOptions,
useInfiniteQuery,
UseInfiniteQueryOptions,
} from '@tanstack/react-query';
import { forecastingService } from '../services/forecasting';
import {
ForecastRequest,
ForecastResponse,
BatchForecastRequest,
BatchForecastResponse,
ListForecastsParams,
ForecastStatisticsParams,
} from '../types/forecasting';
import { ApiError } from '../client/apiClient';
// ================================================================
// QUERY KEYS
// ================================================================
export const forecastingKeys = {
all: ['forecasting'] as const,
lists: () => [...forecastingKeys.all, 'list'] as const,
list: (tenantId: string, filters?: ListForecastsParams) =>
[...forecastingKeys.lists(), tenantId, filters] as const,
details: () => [...forecastingKeys.all, 'detail'] as const,
detail: (tenantId: string, forecastId: string) =>
[...forecastingKeys.details(), tenantId, forecastId] as const,
statistics: (tenantId: string) =>
[...forecastingKeys.all, 'statistics', tenantId] as const,
health: () => [...forecastingKeys.all, 'health'] as const,
} as const;
// ================================================================
// QUERIES
// ================================================================
/**
* Get tenant forecasts with filtering and pagination
*/
export const useTenantForecasts = (
tenantId: string,
params?: ListForecastsParams,
options?: Omit<UseQueryOptions<{ forecasts: ForecastResponse[]; total: number }, ApiError>, 'queryKey' | 'queryFn'>
) => {
return useQuery<{ forecasts: ForecastResponse[]; total: number }, ApiError>({
queryKey: forecastingKeys.list(tenantId, params),
queryFn: () => forecastingService.getTenantForecasts(tenantId, params),
staleTime: 2 * 60 * 1000, // 2 minutes
enabled: !!tenantId,
...options,
});
};
/**
* Get specific forecast by ID
*/
export const useForecastById = (
tenantId: string,
forecastId: string,
options?: Omit<UseQueryOptions<ForecastResponse, ApiError>, 'queryKey' | 'queryFn'>
) => {
return useQuery<ForecastResponse, ApiError>({
queryKey: forecastingKeys.detail(tenantId, forecastId),
queryFn: () => forecastingService.getForecastById(tenantId, forecastId),
staleTime: 5 * 60 * 1000, // 5 minutes
enabled: !!tenantId && !!forecastId,
...options,
});
};
/**
* Get forecast statistics for tenant
*/
export const useForecastStatistics = (
tenantId: string,
options?: Omit<UseQueryOptions<any, ApiError>, 'queryKey' | 'queryFn'>
) => {
return useQuery<any, ApiError>({
queryKey: forecastingKeys.statistics(tenantId),
queryFn: () => forecastingService.getForecastStatistics(tenantId),
staleTime: 5 * 60 * 1000, // 5 minutes
enabled: !!tenantId,
...options,
});
};
/**
* Health check for forecasting service
*/
export const useForecastingHealth = (
options?: Omit<UseQueryOptions<{ status: string; service: string }, ApiError>, 'queryKey' | 'queryFn'>
) => {
return useQuery<{ status: string; service: string }, ApiError>({
queryKey: forecastingKeys.health(),
queryFn: () => forecastingService.getHealthCheck(),
staleTime: 30 * 1000, // 30 seconds
...options,
});
};
// ================================================================
// INFINITE QUERIES
// ================================================================
/**
* Infinite query for tenant forecasts (for pagination)
*/
export const useInfiniteTenantForecasts = (
tenantId: string,
baseParams?: Omit<ListForecastsParams, 'skip' | 'limit'>,
options?: Omit<UseInfiniteQueryOptions<{ forecasts: ForecastResponse[]; total: number }, ApiError>, 'queryKey' | 'queryFn' | 'getNextPageParam' | 'initialPageParam' | 'select'>
) => {
const limit = 20;
return useInfiniteQuery<{ forecasts: ForecastResponse[]; total: number }, ApiError>({
queryKey: [...forecastingKeys.list(tenantId, baseParams), 'infinite'],
queryFn: ({ pageParam = 0 }) => {
const params: ListForecastsParams = {
...baseParams,
skip: pageParam as number,
limit,
};
return forecastingService.getTenantForecasts(tenantId, params);
},
initialPageParam: 0,
getNextPageParam: (lastPage, allPages) => {
const totalFetched = allPages.reduce((sum, page) => sum + page.forecasts.length, 0);
return lastPage.forecasts.length === limit ? totalFetched : undefined;
},
staleTime: 2 * 60 * 1000, // 2 minutes
enabled: !!tenantId,
...options,
});
};
// ================================================================
// MUTATIONS
// ================================================================
/**
* Create single forecast mutation
*/
export const useCreateSingleForecast = (
options?: UseMutationOptions<
ForecastResponse,
ApiError,
{ tenantId: string; request: ForecastRequest }
>
) => {
const queryClient = useQueryClient();
return useMutation<
ForecastResponse,
ApiError,
{ tenantId: string; request: ForecastRequest }
>({
mutationFn: ({ tenantId, request }) =>
forecastingService.createSingleForecast(tenantId, request),
onSuccess: (data, variables) => {
// Invalidate and refetch forecasts list
queryClient.invalidateQueries({
queryKey: forecastingKeys.lists(),
});
// Update the specific forecast cache
queryClient.setQueryData(
forecastingKeys.detail(variables.tenantId, data.id),
{
...data,
enhanced_features: true,
repository_integration: true,
} as ForecastByIdResponse
);
// Update statistics
queryClient.invalidateQueries({
queryKey: forecastingKeys.statistics(variables.tenantId),
});
},
...options,
});
};
/**
* Create batch forecast mutation
*/
export const useCreateBatchForecast = (
options?: UseMutationOptions<
BatchForecastResponse,
ApiError,
{ tenantId: string; request: BatchForecastRequest }
>
) => {
const queryClient = useQueryClient();
return useMutation<
BatchForecastResponse,
ApiError,
{ tenantId: string; request: BatchForecastRequest }
>({
mutationFn: ({ tenantId, request }) =>
forecastingService.createBatchForecast(tenantId, request),
onSuccess: (data, variables) => {
// Invalidate forecasts list
queryClient.invalidateQueries({
queryKey: forecastingKeys.lists(),
});
// Cache individual forecasts if available
if (data.forecasts) {
data.forecasts.forEach((forecast) => {
queryClient.setQueryData(
forecastingKeys.detail(variables.tenantId, forecast.id),
forecast
);
});
}
// Update statistics
queryClient.invalidateQueries({
queryKey: forecastingKeys.statistics(variables.tenantId),
});
},
...options,
});
};
/**
* Delete forecast mutation
*/
export const useDeleteForecast = (
options?: UseMutationOptions<
{ message: string },
ApiError,
{ tenantId: string; forecastId: string }
>
) => {
const queryClient = useQueryClient();
return useMutation<
{ message: string },
ApiError,
{ tenantId: string; forecastId: string }
>({
mutationFn: ({ tenantId, forecastId }) =>
forecastingService.deleteForecast(tenantId, forecastId),
onSuccess: (data, variables) => {
// Remove from cache
queryClient.removeQueries({
queryKey: forecastingKeys.detail(variables.tenantId, variables.forecastId),
});
// Invalidate lists to refresh
queryClient.invalidateQueries({
queryKey: forecastingKeys.lists(),
});
// Update statistics
queryClient.invalidateQueries({
queryKey: forecastingKeys.statistics(variables.tenantId),
});
},
...options,
});
};
// ================================================================
// UTILITY FUNCTIONS
// ================================================================
/**
* Prefetch forecast by ID
*/
export const usePrefetchForecast = () => {
const queryClient = useQueryClient();
return (tenantId: string, forecastId: string) => {
queryClient.prefetchQuery({
queryKey: forecastingKeys.detail(tenantId, forecastId),
queryFn: () => forecastingService.getForecastById(tenantId, forecastId),
staleTime: 5 * 60 * 1000, // 5 minutes
});
};
};
/**
* Invalidate all forecasting queries for a tenant
*/
export const useInvalidateForecasting = () => {
const queryClient = useQueryClient();
return (tenantId?: string) => {
if (tenantId) {
// Invalidate specific tenant queries
queryClient.invalidateQueries({
queryKey: forecastingKeys.list(tenantId),
});
queryClient.invalidateQueries({
queryKey: forecastingKeys.statistics(tenantId),
});
} else {
// Invalidate all forecasting queries
queryClient.invalidateQueries({
queryKey: forecastingKeys.all,
});
}
};
};

View File

@@ -0,0 +1,802 @@
/**
* Inventory React Query hooks
*/
import { useMutation, useQuery, useQueryClient, UseQueryOptions, UseMutationOptions } from '@tanstack/react-query';
import { inventoryService } from '../services/inventory';
// inventoryService merged into inventoryService
import {
IngredientCreate,
IngredientUpdate,
IngredientResponse,
BulkIngredientResponse,
StockCreate,
StockUpdate,
StockResponse,
StockMovementCreate,
StockMovementResponse,
InventoryFilter,
StockFilter,
StockConsumptionRequest,
StockConsumptionResponse,
PaginatedResponse,
ProductTransformationCreate,
ProductTransformationResponse,
ProductionStage,
DeletionSummary,
BulkStockResponse,
} from '../types/inventory';
import { ApiError } from '../client';
// Query Keys
export const inventoryKeys = {
all: ['inventory'] as const,
ingredients: {
all: () => [...inventoryKeys.all, 'ingredients'] as const,
lists: () => [...inventoryKeys.ingredients.all(), 'list'] as const,
list: (tenantId: string, filters?: InventoryFilter) =>
[...inventoryKeys.ingredients.lists(), tenantId, filters] as const,
details: () => [...inventoryKeys.ingredients.all(), 'detail'] as const,
detail: (tenantId: string, ingredientId: string) =>
[...inventoryKeys.ingredients.details(), tenantId, ingredientId] as const,
byCategory: (tenantId: string) =>
[...inventoryKeys.ingredients.all(), 'by-category', tenantId] as const,
lowStock: (tenantId: string) =>
[...inventoryKeys.ingredients.all(), 'low-stock', tenantId] as const,
},
stock: {
all: () => [...inventoryKeys.all, 'stock'] as const,
lists: () => [...inventoryKeys.stock.all(), 'list'] as const,
list: (tenantId: string, filters?: StockFilter) =>
[...inventoryKeys.stock.lists(), tenantId, filters] as const,
details: () => [...inventoryKeys.stock.all(), 'detail'] as const,
detail: (tenantId: string, stockId: string) =>
[...inventoryKeys.stock.details(), tenantId, stockId] as const,
byIngredient: (tenantId: string, ingredientId: string, includeUnavailable?: boolean) =>
[...inventoryKeys.stock.all(), 'by-ingredient', tenantId, ingredientId, includeUnavailable] as const,
expiring: (tenantId: string, withinDays?: number) =>
[...inventoryKeys.stock.all(), 'expiring', tenantId, withinDays] as const,
expired: (tenantId: string) =>
[...inventoryKeys.stock.all(), 'expired', tenantId] as const,
movements: (tenantId: string, ingredientId?: string) =>
[...inventoryKeys.stock.all(), 'movements', tenantId, ingredientId] as const,
},
analytics: (tenantId: string, startDate?: string, endDate?: string) =>
[...inventoryKeys.all, 'analytics', tenantId, { startDate, endDate }] as const,
transformations: {
all: () => [...inventoryKeys.all, 'transformations'] as const,
lists: () => [...inventoryKeys.transformations.all(), 'list'] as const,
list: (tenantId: string, filters?: any) =>
[...inventoryKeys.transformations.lists(), tenantId, filters] as const,
details: () => [...inventoryKeys.transformations.all(), 'detail'] as const,
detail: (tenantId: string, transformationId: string) =>
[...inventoryKeys.transformations.details(), tenantId, transformationId] as const,
summary: (tenantId: string, daysBack?: number) =>
[...inventoryKeys.transformations.all(), 'summary', tenantId, daysBack] as const,
byIngredient: (tenantId: string, ingredientId: string) =>
[...inventoryKeys.transformations.all(), 'by-ingredient', tenantId, ingredientId] as const,
byStage: (tenantId: string, sourceStage?: ProductionStage, targetStage?: ProductionStage) =>
[...inventoryKeys.transformations.all(), 'by-stage', tenantId, { sourceStage, targetStage }] as const,
},
} as const;
// Ingredient Queries
export const useIngredients = (
tenantId: string,
filter?: InventoryFilter,
options?: Omit<UseQueryOptions<IngredientResponse[], ApiError>, 'queryKey' | 'queryFn'>
) => {
return useQuery<IngredientResponse[], ApiError>({
queryKey: inventoryKeys.ingredients.list(tenantId, filter),
queryFn: () => inventoryService.getIngredients(tenantId, filter),
enabled: !!tenantId,
staleTime: 2 * 60 * 1000, // 2 minutes
...options,
});
};
export const useIngredient = (
tenantId: string,
ingredientId: string,
options?: Omit<UseQueryOptions<IngredientResponse, ApiError>, 'queryKey' | 'queryFn'>
) => {
return useQuery<IngredientResponse, ApiError>({
queryKey: inventoryKeys.ingredients.detail(tenantId, ingredientId),
queryFn: () => inventoryService.getIngredient(tenantId, ingredientId),
enabled: !!tenantId && !!ingredientId,
staleTime: 5 * 60 * 1000, // 5 minutes
...options,
});
};
export const useIngredientsByCategory = (
tenantId: string,
options?: Omit<UseQueryOptions<Record<string, IngredientResponse[]>, ApiError>, 'queryKey' | 'queryFn'>
) => {
return useQuery<Record<string, IngredientResponse[]>, ApiError>({
queryKey: inventoryKeys.ingredients.byCategory(tenantId),
queryFn: () => inventoryService.getIngredientsByCategory(tenantId),
enabled: !!tenantId,
staleTime: 5 * 60 * 1000, // 5 minutes
...options,
});
};
export const useLowStockIngredients = (
tenantId: string,
options?: Omit<UseQueryOptions<IngredientResponse[], ApiError>, 'queryKey' | 'queryFn'>
) => {
return useQuery<IngredientResponse[], ApiError>({
queryKey: inventoryKeys.ingredients.lowStock(tenantId),
queryFn: () => inventoryService.getLowStockIngredients(tenantId),
enabled: !!tenantId,
staleTime: 30 * 1000, // 30 seconds
...options,
});
};
// Stock Queries
export const useStock = (
tenantId: string,
filter?: StockFilter,
options?: Omit<UseQueryOptions<PaginatedResponse<StockResponse>, ApiError>, 'queryKey' | 'queryFn'>
) => {
return useQuery<PaginatedResponse<StockResponse>, ApiError>({
queryKey: inventoryKeys.stock.list(tenantId, filter),
queryFn: () => inventoryService.getAllStock(tenantId, filter),
enabled: !!tenantId,
staleTime: 30 * 1000, // 30 seconds
...options,
});
};
export const useStockByIngredient = (
tenantId: string,
ingredientId: string,
includeUnavailable: boolean = false,
options?: Omit<UseQueryOptions<StockResponse[], ApiError>, 'queryKey' | 'queryFn'>
) => {
return useQuery<StockResponse[], ApiError>({
queryKey: inventoryKeys.stock.byIngredient(tenantId, ingredientId, includeUnavailable),
queryFn: () => inventoryService.getStockByIngredient(tenantId, ingredientId, includeUnavailable),
enabled: !!tenantId && !!ingredientId,
staleTime: 1 * 60 * 1000, // 1 minute
...options,
});
};
export const useExpiringStock = (
tenantId: string,
withinDays: number = 7,
options?: Omit<UseQueryOptions<StockResponse[], ApiError>, 'queryKey' | 'queryFn'>
) => {
return useQuery<StockResponse[], ApiError>({
queryKey: inventoryKeys.stock.expiring(tenantId, withinDays),
queryFn: () => inventoryService.getExpiringStock(tenantId, withinDays),
enabled: !!tenantId,
staleTime: 30 * 1000, // 30 seconds
...options,
});
};
export const useExpiredStock = (
tenantId: string,
options?: Omit<UseQueryOptions<StockResponse[], ApiError>, 'queryKey' | 'queryFn'>
) => {
return useQuery<StockResponse[], ApiError>({
queryKey: inventoryKeys.stock.expired(tenantId),
queryFn: () => inventoryService.getExpiredStock(tenantId),
enabled: !!tenantId,
staleTime: 1 * 60 * 1000, // 1 minute
...options,
});
};
export const useStockMovements = (
tenantId: string,
ingredientId?: string,
limit: number = 50,
offset: number = 0,
options?: Omit<UseQueryOptions<StockMovementResponse[], ApiError>, 'queryKey' | 'queryFn'>
) => {
// Validate UUID format if ingredientId is provided
const isValidUUID = (uuid?: string): boolean => {
if (!uuid) return true; // undefined/null is valid (means no filter)
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
return uuidRegex.test(uuid);
};
const validIngredientId = ingredientId && isValidUUID(ingredientId) ? ingredientId : undefined;
// Log warning if ingredient ID is invalid
if (ingredientId && !isValidUUID(ingredientId)) {
console.warn('[useStockMovements] Invalid ingredient ID format:', ingredientId);
}
return useQuery<StockMovementResponse[], ApiError>({
queryKey: inventoryKeys.stock.movements(tenantId, validIngredientId),
queryFn: () => inventoryService.getStockMovements(tenantId, validIngredientId, limit, offset),
enabled: !!tenantId && (!ingredientId || isValidUUID(ingredientId)),
staleTime: 1 * 60 * 1000, // 1 minute
...options,
});
};
export const useStockAnalytics = (
tenantId: string,
startDate?: string,
endDate?: string,
options?: Omit<UseQueryOptions<any, ApiError>, 'queryKey' | 'queryFn'>
) => {
return useQuery<any, ApiError>({
queryKey: inventoryKeys.analytics(tenantId, startDate, endDate),
queryFn: () => inventoryService.getStockAnalytics(tenantId, startDate, endDate),
enabled: !!tenantId,
staleTime: 5 * 60 * 1000, // 5 minutes
...options,
});
};
// Ingredient Mutations
export const useCreateIngredient = (
options?: UseMutationOptions<IngredientResponse, ApiError, { tenantId: string; ingredientData: IngredientCreate }>
) => {
const queryClient = useQueryClient();
return useMutation<IngredientResponse, ApiError, { tenantId: string; ingredientData: IngredientCreate }>({
mutationFn: ({ tenantId, ingredientData }) => inventoryService.createIngredient(tenantId, ingredientData),
onSuccess: (data, { tenantId }) => {
// Add to cache
queryClient.setQueryData(inventoryKeys.ingredients.detail(tenantId, data.id), data);
// Invalidate lists
queryClient.invalidateQueries({ queryKey: inventoryKeys.ingredients.lists() });
queryClient.invalidateQueries({ queryKey: inventoryKeys.ingredients.byCategory(tenantId) });
},
...options,
});
};
export const useBulkCreateIngredients = (
options?: UseMutationOptions<BulkIngredientResponse, ApiError, { tenantId: string; ingredients: IngredientCreate[] }>
) => {
const queryClient = useQueryClient();
return useMutation<BulkIngredientResponse, ApiError, { tenantId: string; ingredients: IngredientCreate[] }>({
mutationFn: ({ tenantId, ingredients }) => inventoryService.bulkCreateIngredients(tenantId, ingredients),
onSuccess: (data, { tenantId }) => {
// Invalidate all ingredient lists to refetch
queryClient.invalidateQueries({ queryKey: inventoryKeys.ingredients.lists() });
queryClient.invalidateQueries({ queryKey: inventoryKeys.ingredients.byCategory(tenantId) });
},
...options,
});
};
export const useUpdateIngredient = (
options?: UseMutationOptions<
IngredientResponse,
ApiError,
{ tenantId: string; ingredientId: string; updateData: IngredientUpdate }
>
) => {
const queryClient = useQueryClient();
return useMutation<
IngredientResponse,
ApiError,
{ tenantId: string; ingredientId: string; updateData: IngredientUpdate }
>({
mutationFn: ({ tenantId, ingredientId, updateData }) =>
inventoryService.updateIngredient(tenantId, ingredientId, updateData),
onSuccess: (data, { tenantId, ingredientId }) => {
// Update cache
queryClient.setQueryData(inventoryKeys.ingredients.detail(tenantId, ingredientId), data);
// Invalidate lists
queryClient.invalidateQueries({ queryKey: inventoryKeys.ingredients.lists() });
queryClient.invalidateQueries({ queryKey: inventoryKeys.ingredients.byCategory(tenantId) });
},
...options,
});
};
export const useSoftDeleteIngredient = (
options?: UseMutationOptions<void, ApiError, { tenantId: string; ingredientId: string }>
) => {
const queryClient = useQueryClient();
return useMutation<void, ApiError, { tenantId: string; ingredientId: string }>({
mutationFn: ({ tenantId, ingredientId }) => inventoryService.softDeleteIngredient(tenantId, ingredientId),
onSuccess: (data, { tenantId, ingredientId }) => {
// Remove from cache
queryClient.removeQueries({ queryKey: inventoryKeys.ingredients.detail(tenantId, ingredientId) });
// Invalidate lists to reflect the soft deletion (item marked as inactive)
queryClient.invalidateQueries({ queryKey: inventoryKeys.ingredients.lists() });
queryClient.invalidateQueries({ queryKey: inventoryKeys.ingredients.byCategory(tenantId) });
queryClient.invalidateQueries({ queryKey: inventoryKeys.stock.lists() });
},
...options,
});
};
export const useHardDeleteIngredient = (
options?: UseMutationOptions<DeletionSummary, ApiError, { tenantId: string; ingredientId: string }>
) => {
const queryClient = useQueryClient();
return useMutation<DeletionSummary, ApiError, { tenantId: string; ingredientId: string }>({
mutationFn: ({ tenantId, ingredientId }) => inventoryService.hardDeleteIngredient(tenantId, ingredientId),
onSuccess: (data, { tenantId, ingredientId }) => {
// Remove from cache completely
queryClient.removeQueries({ queryKey: inventoryKeys.ingredients.detail(tenantId, ingredientId) });
// Invalidate all related data since everything was deleted
queryClient.invalidateQueries({ queryKey: inventoryKeys.ingredients.lists() });
queryClient.invalidateQueries({ queryKey: inventoryKeys.ingredients.byCategory(tenantId) });
queryClient.invalidateQueries({ queryKey: inventoryKeys.stock.lists() });
queryClient.invalidateQueries({ queryKey: inventoryKeys.stock.movements(tenantId) });
queryClient.invalidateQueries({ queryKey: inventoryKeys.all });
},
...options,
});
};
// Stock Mutations
export const useAddStock = (
options?: UseMutationOptions<StockResponse, ApiError, { tenantId: string; stockData: StockCreate }>
) => {
const queryClient = useQueryClient();
return useMutation<StockResponse, ApiError, { tenantId: string; stockData: StockCreate }>({
mutationFn: ({ tenantId, stockData }) => inventoryService.addStock(tenantId, stockData),
onSuccess: (data, { tenantId }) => {
// Invalidate stock queries
queryClient.invalidateQueries({ queryKey: inventoryKeys.stock.lists() });
queryClient.invalidateQueries({ queryKey: inventoryKeys.stock.byIngredient(tenantId, data.ingredient_id) });
queryClient.invalidateQueries({ queryKey: inventoryKeys.ingredients.lists() });
},
...options,
});
};
export const useBulkAddStock = (
options?: UseMutationOptions<BulkStockResponse, ApiError, { tenantId: string; stocks: StockCreate[] }>
) => {
const queryClient = useQueryClient();
return useMutation<BulkStockResponse, ApiError, { tenantId: string; stocks: StockCreate[] }>({
mutationFn: ({ tenantId, stocks }) => inventoryService.bulkAddStock(tenantId, stocks),
onSuccess: (data, { tenantId }) => {
// Invalidate all stock queries since multiple ingredients may have been affected
queryClient.invalidateQueries({ queryKey: inventoryKeys.stock.lists() });
queryClient.invalidateQueries({ queryKey: inventoryKeys.ingredients.lists() });
// Invalidate per-ingredient stock queries
data.results.forEach((result) => {
if (result.success && result.stock) {
queryClient.invalidateQueries({
queryKey: inventoryKeys.stock.byIngredient(tenantId, result.stock.ingredient_id)
});
}
});
},
...options,
});
};
export const useUpdateStock = (
options?: UseMutationOptions<
StockResponse,
ApiError,
{ tenantId: string; stockId: string; updateData: StockUpdate }
>
) => {
const queryClient = useQueryClient();
return useMutation<
StockResponse,
ApiError,
{ tenantId: string; stockId: string; updateData: StockUpdate }
>({
mutationFn: ({ tenantId, stockId, updateData }) =>
inventoryService.updateStock(tenantId, stockId, updateData),
onSuccess: (data, { tenantId, stockId }) => {
// Update cache
queryClient.setQueryData(inventoryKeys.stock.detail(tenantId, stockId), data);
// Invalidate related queries
queryClient.invalidateQueries({ queryKey: inventoryKeys.stock.lists() });
queryClient.invalidateQueries({ queryKey: inventoryKeys.stock.byIngredient(tenantId, data.ingredient_id) });
},
...options,
});
};
export const useConsumeStock = (
options?: UseMutationOptions<
StockConsumptionResponse,
ApiError,
{ tenantId: string; consumptionData: StockConsumptionRequest }
>
) => {
const queryClient = useQueryClient();
return useMutation<
StockConsumptionResponse,
ApiError,
{ tenantId: string; consumptionData: StockConsumptionRequest }
>({
mutationFn: ({ tenantId, consumptionData }) => inventoryService.consumeStock(tenantId, consumptionData),
onSuccess: (data, { tenantId, consumptionData }) => {
// Invalidate stock queries for the affected ingredient
queryClient.invalidateQueries({
queryKey: inventoryKeys.stock.byIngredient(tenantId, consumptionData.ingredient_id)
});
queryClient.invalidateQueries({ queryKey: inventoryKeys.stock.lists() });
queryClient.invalidateQueries({ queryKey: inventoryKeys.stock.movements(tenantId) });
queryClient.invalidateQueries({ queryKey: inventoryKeys.ingredients.lists() });
},
...options,
});
};
export const useCreateStockMovement = (
options?: UseMutationOptions<
StockMovementResponse,
ApiError,
{ tenantId: string; movementData: StockMovementCreate }
>
) => {
const queryClient = useQueryClient();
return useMutation<
StockMovementResponse,
ApiError,
{ tenantId: string; movementData: StockMovementCreate }
>({
mutationFn: ({ tenantId, movementData }) => inventoryService.createStockMovement(tenantId, movementData),
onSuccess: (data, { tenantId, movementData }) => {
// Invalidate movement queries
queryClient.invalidateQueries({ queryKey: inventoryKeys.stock.movements(tenantId) });
queryClient.invalidateQueries({
queryKey: inventoryKeys.stock.movements(tenantId, movementData.ingredient_id)
});
// Invalidate stock queries if this affects stock levels
if (['in', 'out', 'adjustment'].includes(movementData.movement_type)) {
queryClient.invalidateQueries({ queryKey: inventoryKeys.stock.lists() });
queryClient.invalidateQueries({
queryKey: inventoryKeys.stock.byIngredient(tenantId, movementData.ingredient_id)
});
queryClient.invalidateQueries({ queryKey: inventoryKeys.ingredients.lists() });
}
},
...options,
});
};
// Custom hooks for stock management operations
export const useStockOperations = (tenantId: string) => {
const queryClient = useQueryClient();
const addStock = useMutation({
mutationFn: async ({ ingredientId, quantity, unit_cost, notes }: {
ingredientId: string;
quantity: number;
unit_cost?: number;
notes?: string;
}) => {
// Create stock entry via backend API
const stockData: StockCreate = {
ingredient_id: ingredientId,
unit_price: unit_cost || 0,
notes
};
return inventoryService.addStock(tenantId, stockData);
},
onSuccess: (data, variables) => {
// Invalidate relevant queries
queryClient.invalidateQueries({ queryKey: inventoryKeys.ingredients.lists() });
queryClient.invalidateQueries({ queryKey: inventoryKeys.stock.movements(tenantId) });
queryClient.invalidateQueries({ queryKey: inventoryKeys.stock.movements(tenantId, variables.ingredientId) });
}
});
const consumeStock = useMutation({
mutationFn: async ({ ingredientId, quantity, reference_number, notes, fifo = true }: {
ingredientId: string;
quantity: number;
reference_number?: string;
notes?: string;
fifo?: boolean;
}) => {
const consumptionData: StockConsumptionRequest = {
ingredient_id: ingredientId,
quantity,
reference_number,
notes,
fifo
};
return inventoryService.consumeStock(tenantId, consumptionData);
},
onSuccess: (data, variables) => {
// Invalidate relevant queries
queryClient.invalidateQueries({ queryKey: inventoryKeys.ingredients.lists() });
queryClient.invalidateQueries({ queryKey: inventoryKeys.stock.movements(tenantId) });
queryClient.invalidateQueries({ queryKey: inventoryKeys.stock.movements(tenantId, variables.ingredientId) });
}
});
const adjustStock = useMutation({
mutationFn: async ({ ingredientId, quantity, notes }: {
ingredientId: string;
quantity: number;
notes?: string;
}) => {
// Create adjustment movement via backend API
const movementData: StockMovementCreate = {
ingredient_id: ingredientId,
movement_type: 'ADJUSTMENT' as any,
quantity,
notes
};
return inventoryService.createStockMovement(tenantId, movementData);
},
onSuccess: (data, variables) => {
// Invalidate relevant queries
queryClient.invalidateQueries({ queryKey: inventoryKeys.ingredients.lists() });
queryClient.invalidateQueries({ queryKey: inventoryKeys.stock.movements(tenantId) });
queryClient.invalidateQueries({ queryKey: inventoryKeys.stock.movements(tenantId, variables.ingredientId) });
}
});
return {
addStock,
consumeStock,
adjustStock
};
};
// ===== TRANSFORMATION HOOKS =====
export const useTransformations = (
tenantId: string,
options?: {
skip?: number;
limit?: number;
ingredient_id?: string;
source_stage?: ProductionStage;
target_stage?: ProductionStage;
days_back?: number;
},
queryOptions?: Omit<UseQueryOptions<ProductTransformationResponse[], ApiError>, 'queryKey' | 'queryFn'>
) => {
return useQuery<ProductTransformationResponse[], ApiError>({
queryKey: inventoryKeys.transformations.list(tenantId, options),
queryFn: () => inventoryService.getTransformations(tenantId, options),
enabled: !!tenantId,
staleTime: 1 * 60 * 1000, // 1 minute
...queryOptions,
});
};
export const useTransformation = (
tenantId: string,
transformationId: string,
options?: Omit<UseQueryOptions<ProductTransformationResponse, ApiError>, 'queryKey' | 'queryFn'>
) => {
return useQuery<ProductTransformationResponse, ApiError>({
queryKey: inventoryKeys.transformations.detail(tenantId, transformationId),
queryFn: () => inventoryService.getTransformation(tenantId, transformationId),
enabled: !!tenantId && !!transformationId,
staleTime: 2 * 60 * 1000, // 2 minutes
...options,
});
};
export const useTransformationSummary = (
tenantId: string,
daysBack: number = 30,
options?: Omit<UseQueryOptions<any, ApiError>, 'queryKey' | 'queryFn'>
) => {
return useQuery<any, ApiError>({
queryKey: inventoryKeys.transformations.summary(tenantId, daysBack),
queryFn: () => inventoryService.getTransformationSummary(tenantId, daysBack),
enabled: !!tenantId,
staleTime: 5 * 60 * 1000, // 5 minutes
...options,
});
};
export const useTransformationsByIngredient = (
tenantId: string,
ingredientId: string,
limit: number = 50,
options?: Omit<UseQueryOptions<ProductTransformationResponse[], ApiError>, 'queryKey' | 'queryFn'>
) => {
return useQuery<ProductTransformationResponse[], ApiError>({
queryKey: inventoryKeys.transformations.byIngredient(tenantId, ingredientId),
queryFn: () => inventoryService.getTransformationsForIngredient(tenantId, ingredientId, limit),
enabled: !!tenantId && !!ingredientId,
staleTime: 2 * 60 * 1000, // 2 minutes
...options,
});
};
export const useTransformationsByStage = (
tenantId: string,
sourceStage?: ProductionStage,
targetStage?: ProductionStage,
limit: number = 50,
options?: Omit<UseQueryOptions<ProductTransformationResponse[], ApiError>, 'queryKey' | 'queryFn'>
) => {
return useQuery<ProductTransformationResponse[], ApiError>({
queryKey: inventoryKeys.transformations.byStage(tenantId, sourceStage, targetStage),
queryFn: () => inventoryService.getTransformationsByStage(tenantId, sourceStage, targetStage, limit),
enabled: !!tenantId,
staleTime: 2 * 60 * 1000, // 2 minutes
...options,
});
};
// ===== TRANSFORMATION MUTATIONS =====
export const useCreateTransformation = (
options?: UseMutationOptions<
ProductTransformationResponse,
ApiError,
{ tenantId: string; transformationData: ProductTransformationCreate }
>
) => {
const queryClient = useQueryClient();
return useMutation<
ProductTransformationResponse,
ApiError,
{ tenantId: string; transformationData: ProductTransformationCreate }
>({
mutationFn: ({ tenantId, transformationData }) =>
inventoryService.createTransformation(tenantId, transformationData),
onSuccess: (data, { tenantId, transformationData }) => {
// Add to cache
queryClient.setQueryData(
inventoryKeys.transformations.detail(tenantId, data.id),
data
);
// Invalidate related queries
queryClient.invalidateQueries({ queryKey: inventoryKeys.transformations.lists() });
queryClient.invalidateQueries({ queryKey: inventoryKeys.stock.lists() });
queryClient.invalidateQueries({ queryKey: inventoryKeys.ingredients.lists() });
queryClient.invalidateQueries({ queryKey: inventoryKeys.stock.movements(tenantId) });
// Invalidate ingredient-specific queries
queryClient.invalidateQueries({
queryKey: inventoryKeys.transformations.byIngredient(tenantId, transformationData.source_ingredient_id)
});
queryClient.invalidateQueries({
queryKey: inventoryKeys.transformations.byIngredient(tenantId, transformationData.target_ingredient_id)
});
},
...options,
});
};
export const useParBakeTransformation = (
options?: UseMutationOptions<
any,
ApiError,
{
tenantId: string;
source_ingredient_id: string;
target_ingredient_id: string;
quantity: number;
target_batch_number?: string;
expiration_hours?: number;
notes?: string;
}
>
) => {
const queryClient = useQueryClient();
return useMutation<
any,
ApiError,
{
tenantId: string;
source_ingredient_id: string;
target_ingredient_id: string;
quantity: number;
target_batch_number?: string;
expiration_hours?: number;
notes?: string;
}
>({
mutationFn: ({ tenantId, ...transformationOptions }) =>
inventoryService.createParBakeToFreshTransformation(tenantId, transformationOptions),
onSuccess: (data, { tenantId, source_ingredient_id, target_ingredient_id }) => {
// Invalidate related queries
queryClient.invalidateQueries({ queryKey: inventoryKeys.transformations.lists() });
queryClient.invalidateQueries({ queryKey: inventoryKeys.stock.lists() });
queryClient.invalidateQueries({ queryKey: inventoryKeys.ingredients.lists() });
queryClient.invalidateQueries({ queryKey: inventoryKeys.stock.movements(tenantId) });
// Invalidate ingredient-specific queries
queryClient.invalidateQueries({
queryKey: inventoryKeys.transformations.byIngredient(tenantId, source_ingredient_id)
});
queryClient.invalidateQueries({
queryKey: inventoryKeys.transformations.byIngredient(tenantId, target_ingredient_id)
});
},
...options,
});
};
// Custom hook for common transformation operations
export const useTransformationOperations = (tenantId: string) => {
const createTransformation = useCreateTransformation();
const parBakeTransformation = useParBakeTransformation();
const bakeParBakedCroissants = useMutation({
mutationFn: async ({
parBakedIngredientId,
freshBakedIngredientId,
quantity,
expirationHours = 24,
notes,
}: {
parBakedIngredientId: string;
freshBakedIngredientId: string;
quantity: number;
expirationHours?: number;
notes?: string;
}) => {
return inventoryService.bakeParBakedCroissants(
tenantId,
parBakedIngredientId,
freshBakedIngredientId,
quantity,
expirationHours,
notes
);
},
onSuccess: () => {
// Invalidate related queries
createTransformation.reset();
},
});
const transformFrozenToPrepared = useMutation({
mutationFn: async ({
frozenIngredientId,
preparedIngredientId,
quantity,
notes,
}: {
frozenIngredientId: string;
preparedIngredientId: string;
quantity: number;
notes?: string;
}) => {
return inventoryService.transformFrozenToPrepared(
tenantId,
frozenIngredientId,
preparedIngredientId,
quantity,
notes
);
},
onSuccess: () => {
// Invalidate related queries
createTransformation.reset();
},
});
return {
createTransformation,
parBakeTransformation,
bakeParBakedCroissants,
transformFrozenToPrepared,
};
};
// Classification operations
export const useClassifyBatch = (
options?: UseMutationOptions<any, ApiError, { tenantId: string; products: { product_name: string }[] }>
) => {
return useMutation<any, ApiError, { tenantId: string; products: { product_name: string }[] }>({
mutationFn: ({ tenantId, products }) => inventoryService.classifyBatch(tenantId, { products }),
...options,
});
};

View File

@@ -0,0 +1,250 @@
/**
* Onboarding React Query hooks
*/
import { useMutation, useQuery, useQueryClient, UseQueryOptions, UseMutationOptions } from '@tanstack/react-query';
import { useCallback, useRef } from 'react';
import { onboardingService } from '../services/onboarding';
import { UserProgress, UpdateStepRequest, StepDraftResponse } from '../types/onboarding';
import { ApiError } from '../client';
// Query Keys
export const onboardingKeys = {
all: ['onboarding'] as const,
progress: (userId: string) => [...onboardingKeys.all, 'progress', userId] as const,
steps: () => [...onboardingKeys.all, 'steps'] as const,
stepDetail: (stepName: string) => [...onboardingKeys.steps(), stepName] as const,
stepDraft: (stepName: string) => [...onboardingKeys.all, 'draft', stepName] as const,
} as const;
// Queries
export const useUserProgress = (
userId: string,
options?: Omit<UseQueryOptions<UserProgress, ApiError>, 'queryKey' | 'queryFn'>
) => {
return useQuery<UserProgress, ApiError>({
queryKey: onboardingKeys.progress(userId),
queryFn: () => onboardingService.getUserProgress(userId),
enabled: !!userId,
// OPTIMIZATION: Once onboarding is fully completed, it won't change back
// Use longer staleTime (5 min) and gcTime (30 min) to reduce API calls
// The select function below will update staleTime based on completion status
staleTime: 5 * 60 * 1000, // 5 minutes (increased from 30s - completed status rarely changes)
gcTime: 30 * 60 * 1000, // 30 minutes - keep in cache longer
refetchOnWindowFocus: false, // Don't refetch on window focus for onboarding status
...options,
});
};
export const useAllSteps = (
options?: Omit<UseQueryOptions<Array<{
name: string;
description: string;
dependencies: string[];
estimated_time_minutes: number;
}>, ApiError>, 'queryKey' | 'queryFn'>
) => {
return useQuery<Array<{
name: string;
description: string;
dependencies: string[];
estimated_time_minutes: number;
}>, ApiError>({
queryKey: onboardingKeys.steps(),
queryFn: () => onboardingService.getAllSteps(),
staleTime: 10 * 60 * 1000, // 10 minutes
...options,
});
};
export const useStepDetails = (
stepName: string,
options?: Omit<UseQueryOptions<{
name: string;
description: string;
dependencies: string[];
estimated_time_minutes: number;
}, ApiError>, 'queryKey' | 'queryFn'>
) => {
return useQuery<{
name: string;
description: string;
dependencies: string[];
estimated_time_minutes: number;
}, ApiError>({
queryKey: onboardingKeys.stepDetail(stepName),
queryFn: () => onboardingService.getStepDetails(stepName),
enabled: !!stepName,
staleTime: 10 * 60 * 1000, // 10 minutes
...options,
});
};
// Mutations
export const useUpdateStep = (
options?: UseMutationOptions<UserProgress, ApiError, { userId: string; stepData: UpdateStepRequest }>
) => {
const queryClient = useQueryClient();
return useMutation<UserProgress, ApiError, { userId: string; stepData: UpdateStepRequest }>({
mutationFn: ({ userId, stepData }) => onboardingService.updateStep(userId, stepData),
onSuccess: (data, { userId }) => {
// Update progress cache
queryClient.setQueryData(onboardingKeys.progress(userId), data);
},
...options,
});
};
export const useMarkStepCompleted = (
options?: UseMutationOptions<
UserProgress,
ApiError,
{ userId: string; stepName: string; data?: Record<string, any> }
>
) => {
const queryClient = useQueryClient();
return useMutation<
UserProgress,
ApiError,
{ userId: string; stepName: string; data?: Record<string, any> }
>({
mutationFn: ({ userId, stepName, data }) =>
onboardingService.markStepCompleted(userId, stepName, data),
onSuccess: (data, { userId }) => {
// Update progress cache with new data
queryClient.setQueryData(onboardingKeys.progress(userId), data);
// Invalidate the query to ensure fresh data on next access
queryClient.invalidateQueries({ queryKey: onboardingKeys.progress(userId) });
},
onError: (error, { userId, stepName }) => {
console.error(`Failed to complete step ${stepName} for user ${userId}:`, error);
// Invalidate queries on error to ensure we get fresh data
queryClient.invalidateQueries({ queryKey: onboardingKeys.progress(userId) });
},
...options,
});
};
export const useResetProgress = (
options?: UseMutationOptions<UserProgress, ApiError, string>
) => {
const queryClient = useQueryClient();
return useMutation<UserProgress, ApiError, string>({
mutationFn: (userId: string) => onboardingService.resetProgress(userId),
onSuccess: (data, userId) => {
// Update progress cache
queryClient.setQueryData(onboardingKeys.progress(userId), data);
},
...options,
});
};
// Draft Queries and Mutations
/**
* Query hook to get draft data for a specific step
*/
export const useStepDraft = (
stepName: string,
options?: Omit<UseQueryOptions<StepDraftResponse, ApiError>, 'queryKey' | 'queryFn'>
) => {
return useQuery<StepDraftResponse, ApiError>({
queryKey: onboardingKeys.stepDraft(stepName),
queryFn: () => onboardingService.getStepDraft(stepName),
enabled: !!stepName,
staleTime: 30 * 1000, // 30 seconds
...options,
});
};
/**
* Mutation hook to save draft data for a step
*/
export const useSaveStepDraft = (
options?: UseMutationOptions<{ success: boolean }, ApiError, { stepName: string; draftData: Record<string, any> }>
) => {
const queryClient = useQueryClient();
return useMutation<{ success: boolean }, ApiError, { stepName: string; draftData: Record<string, any> }>({
mutationFn: ({ stepName, draftData }) => onboardingService.saveStepDraft(stepName, draftData),
onSuccess: (_, { stepName }) => {
// Invalidate the draft query to get fresh data
queryClient.invalidateQueries({ queryKey: onboardingKeys.stepDraft(stepName) });
},
...options,
});
};
/**
* Mutation hook to delete draft data for a step
*/
export const useDeleteStepDraft = (
options?: UseMutationOptions<{ success: boolean }, ApiError, string>
) => {
const queryClient = useQueryClient();
return useMutation<{ success: boolean }, ApiError, string>({
mutationFn: (stepName: string) => onboardingService.deleteStepDraft(stepName),
onSuccess: (_, stepName) => {
// Invalidate the draft query
queryClient.invalidateQueries({ queryKey: onboardingKeys.stepDraft(stepName) });
},
...options,
});
};
/**
* Custom hook with debounced draft auto-save functionality.
* Automatically saves draft data after a delay when form data changes.
*/
export const useAutoSaveDraft = (stepName: string, debounceMs: number = 2000) => {
const saveStepDraft = useSaveStepDraft();
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
const saveDraft = useCallback(
(draftData: Record<string, any>) => {
// Clear any existing timeout
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
// Set a new timeout for debounced save
timeoutRef.current = setTimeout(() => {
saveStepDraft.mutate({ stepName, draftData });
}, debounceMs);
},
[stepName, debounceMs, saveStepDraft]
);
const saveDraftImmediately = useCallback(
(draftData: Record<string, any>) => {
// Clear any pending debounced save
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
// Save immediately
saveStepDraft.mutate({ stepName, draftData });
},
[stepName, saveStepDraft]
);
const cancelPendingSave = useCallback(() => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
}, []);
return {
saveDraft,
saveDraftImmediately,
cancelPendingSave,
isSaving: saveStepDraft.isPending,
isError: saveStepDraft.isError,
error: saveStepDraft.error,
};
};

View File

@@ -0,0 +1,158 @@
/**
* Orchestrator React Query hooks
*/
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import * as orchestratorService from '../services/orchestrator';
import {
OrchestratorConfig,
OrchestratorStatus,
OrchestratorWorkflowResponse,
WorkflowExecutionDetail,
WorkflowExecutionSummary
} from '../types/orchestrator';
// ============================================================================
// QUERIES
// ============================================================================
export const useOrchestratorStatus = (tenantId: string) => {
return useQuery<OrchestratorStatus>({
queryKey: ['orchestrator', 'status', tenantId],
queryFn: () => orchestratorService.getOrchestratorStatus(tenantId),
enabled: !!tenantId,
refetchInterval: 30000, // Refresh every 30s
});
};
export const useOrchestratorConfig = (tenantId: string) => {
return useQuery<OrchestratorConfig>({
queryKey: ['orchestrator', 'config', tenantId],
queryFn: () => orchestratorService.getOrchestratorConfig(tenantId),
enabled: !!tenantId,
});
};
export const useLatestWorkflowExecution = (tenantId: string) => {
return useQuery<WorkflowExecutionDetail | null>({
queryKey: ['orchestrator', 'executions', 'latest', tenantId],
queryFn: () => orchestratorService.getLatestWorkflowExecution(tenantId),
enabled: !!tenantId,
refetchInterval: (data) => {
// If running, poll more frequently
return data?.status === 'running' ? 5000 : 60000;
},
});
};
export const useWorkflowExecutions = (
tenantId: string,
params?: { limit?: number; offset?: number; status?: string }
) => {
return useQuery<WorkflowExecutionSummary[]>({
queryKey: ['orchestrator', 'executions', tenantId, params],
queryFn: () => orchestratorService.listWorkflowExecutions(tenantId, params),
enabled: !!tenantId,
});
};
export const useWorkflowExecution = (tenantId: string, executionId: string) => {
return useQuery<WorkflowExecutionDetail>({
queryKey: ['orchestrator', 'execution', tenantId, executionId],
queryFn: () => orchestratorService.getWorkflowExecution(tenantId, executionId),
enabled: !!tenantId && !!executionId,
refetchInterval: (data) => {
// If running, poll more frequently
return data?.status === 'running' ? 3000 : false;
},
});
};
// ============================================================================
// MUTATIONS
// ============================================================================
export const useRunDailyWorkflow = (
options?: Parameters<typeof useMutation>[0]
) => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (tenantId: string) =>
orchestratorService.runDailyWorkflow(tenantId),
onSuccess: (_, tenantId) => {
// Invalidate queries to refresh dashboard data after workflow execution
queryClient.invalidateQueries({ queryKey: ['procurement', 'plans'] });
queryClient.invalidateQueries({ queryKey: ['production', 'batches'] });
queryClient.invalidateQueries({ queryKey: ['forecasts'] });
// Also invalidate dashboard queries to refresh stats
queryClient.invalidateQueries({ queryKey: ['dashboard', 'stats'] });
queryClient.invalidateQueries({ queryKey: ['dashboard'] });
// Invalidate orchestrator queries
queryClient.invalidateQueries({ queryKey: ['orchestrator', 'executions'] });
queryClient.invalidateQueries({ queryKey: ['orchestrator', 'status'] });
},
...options,
});
};
export const useTestWorkflow = (
options?: Parameters<typeof useMutation>[0]
) => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (tenantId: string) =>
orchestratorService.testWorkflow(tenantId),
onSuccess: (_, tenantId) => {
queryClient.invalidateQueries({ queryKey: ['orchestrator', 'executions'] });
},
...options,
});
};
export const useUpdateOrchestratorConfig = (
options?: Parameters<typeof useMutation>[0]
) => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ tenantId, config }: { tenantId: string; config: Partial<OrchestratorConfig> }) =>
orchestratorService.updateOrchestratorConfig(tenantId, config),
onSuccess: (_, { tenantId }) => {
queryClient.invalidateQueries({ queryKey: ['orchestrator', 'config', tenantId] });
},
...options,
});
};
export const useCancelWorkflowExecution = (
options?: Parameters<typeof useMutation>[0]
) => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ tenantId, executionId }: { tenantId: string; executionId: string }) =>
orchestratorService.cancelWorkflowExecution(tenantId, executionId),
onSuccess: (_, { tenantId, executionId }) => {
queryClient.invalidateQueries({ queryKey: ['orchestrator', 'execution', tenantId, executionId] });
queryClient.invalidateQueries({ queryKey: ['orchestrator', 'executions', tenantId] });
},
...options,
});
};
export const useRetryWorkflowExecution = (
options?: Parameters<typeof useMutation>[0]
) => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ tenantId, executionId }: { tenantId: string; executionId: string }) =>
orchestratorService.retryWorkflowExecution(tenantId, executionId),
onSuccess: (_, { tenantId, executionId }) => {
queryClient.invalidateQueries({ queryKey: ['orchestrator', 'execution', tenantId, executionId] });
queryClient.invalidateQueries({ queryKey: ['orchestrator', 'executions', tenantId] });
},
...options,
});
};

View File

@@ -0,0 +1,368 @@
/**
* Orders React Query hooks
*/
import { useMutation, useQuery, useQueryClient, UseQueryOptions, UseMutationOptions } from '@tanstack/react-query';
import { OrdersService } from '../services/orders';
import {
OrderResponse,
OrderCreate,
OrderUpdate,
CustomerResponse,
CustomerCreate,
CustomerUpdate,
OrdersDashboardSummary,
DemandRequirements,
BusinessModelDetection,
ServiceStatus,
GetOrdersParams,
GetCustomersParams,
UpdateOrderStatusParams,
GetDemandRequirementsParams,
} from '../types/orders';
import { ApiError } from '../client/apiClient';
// Query Keys
export const ordersKeys = {
all: ['orders'] as const,
// Orders
orders: () => [...ordersKeys.all, 'orders'] as const,
ordersList: (params: GetOrdersParams) => [...ordersKeys.orders(), 'list', params] as const,
order: (tenantId: string, orderId: string) => [...ordersKeys.orders(), 'detail', tenantId, orderId] as const,
// Customers
customers: () => [...ordersKeys.all, 'customers'] as const,
customersList: (params: GetCustomersParams) => [...ordersKeys.customers(), 'list', params] as const,
customer: (tenantId: string, customerId: string) => [...ordersKeys.customers(), 'detail', tenantId, customerId] as const,
// Dashboard & Analytics
dashboard: (tenantId: string) => [...ordersKeys.all, 'dashboard', tenantId] as const,
demandRequirements: (params: GetDemandRequirementsParams) => [...ordersKeys.all, 'demand', params] as const,
businessModel: (tenantId: string) => [...ordersKeys.all, 'business-model', tenantId] as const,
// Status
status: (tenantId: string) => [...ordersKeys.all, 'status', tenantId] as const,
} as const;
// ===== Order Queries =====
export const useOrders = (
params: GetOrdersParams,
options?: Omit<UseQueryOptions<OrderResponse[], ApiError>, 'queryKey' | 'queryFn'>
) => {
return useQuery<OrderResponse[], ApiError>({
queryKey: ordersKeys.ordersList(params),
queryFn: () => OrdersService.getOrders(params),
staleTime: 2 * 60 * 1000, // 2 minutes
...options,
});
};
export const useOrder = (
tenantId: string,
orderId: string,
options?: Omit<UseQueryOptions<OrderResponse, ApiError>, 'queryKey' | 'queryFn'>
) => {
return useQuery<OrderResponse, ApiError>({
queryKey: ordersKeys.order(tenantId, orderId),
queryFn: () => OrdersService.getOrder(tenantId, orderId),
staleTime: 1 * 60 * 1000, // 1 minute
enabled: !!tenantId && !!orderId,
...options,
});
};
// ===== Customer Queries =====
export const useCustomers = (
params: GetCustomersParams,
options?: Omit<UseQueryOptions<CustomerResponse[], ApiError>, 'queryKey' | 'queryFn'>
) => {
return useQuery<CustomerResponse[], ApiError>({
queryKey: ordersKeys.customersList(params),
queryFn: () => OrdersService.getCustomers(params),
staleTime: 5 * 60 * 1000, // 5 minutes
...options,
});
};
export const useCustomer = (
tenantId: string,
customerId: string,
options?: Omit<UseQueryOptions<CustomerResponse, ApiError>, 'queryKey' | 'queryFn'>
) => {
return useQuery<CustomerResponse, ApiError>({
queryKey: ordersKeys.customer(tenantId, customerId),
queryFn: () => OrdersService.getCustomer(tenantId, customerId),
staleTime: 5 * 60 * 1000, // 5 minutes
enabled: !!tenantId && !!customerId,
...options,
});
};
// ===== Dashboard & Analytics Queries =====
export const useOrdersDashboard = (
tenantId: string,
options?: Omit<UseQueryOptions<OrdersDashboardSummary, ApiError>, 'queryKey' | 'queryFn'>
) => {
return useQuery<OrdersDashboardSummary, ApiError>({
queryKey: ordersKeys.dashboard(tenantId),
queryFn: () => OrdersService.getDashboardSummary(tenantId),
staleTime: 1 * 60 * 1000, // 1 minute
enabled: !!tenantId,
...options,
});
};
export const useDemandRequirements = (
params: GetDemandRequirementsParams,
options?: Omit<UseQueryOptions<DemandRequirements, ApiError>, 'queryKey' | 'queryFn'>
) => {
return useQuery<DemandRequirements, ApiError>({
queryKey: ordersKeys.demandRequirements(params),
queryFn: () => OrdersService.getDemandRequirements(params),
staleTime: 30 * 60 * 1000, // 30 minutes
enabled: !!params.tenant_id && !!params.target_date,
...options,
});
};
export const useBusinessModelDetection = (
tenantId: string,
options?: Omit<UseQueryOptions<BusinessModelDetection, ApiError>, 'queryKey' | 'queryFn'>
) => {
return useQuery<BusinessModelDetection, ApiError>({
queryKey: ordersKeys.businessModel(tenantId),
queryFn: () => OrdersService.detectBusinessModel(tenantId),
staleTime: 60 * 60 * 1000, // 1 hour
enabled: !!tenantId,
...options,
});
};
export const useOrdersServiceStatus = (
tenantId: string,
options?: Omit<UseQueryOptions<ServiceStatus, ApiError>, 'queryKey' | 'queryFn'>
) => {
return useQuery<ServiceStatus, ApiError>({
queryKey: ordersKeys.status(tenantId),
queryFn: () => OrdersService.getServiceStatus(tenantId),
staleTime: 30 * 1000, // 30 seconds
enabled: !!tenantId,
...options,
});
};
// ===== Order Mutations =====
export const useCreateOrder = (
options?: UseMutationOptions<OrderResponse, ApiError, OrderCreate>
) => {
const queryClient = useQueryClient();
return useMutation<OrderResponse, ApiError, OrderCreate>({
mutationFn: (orderData: OrderCreate) => OrdersService.createOrder(orderData),
onSuccess: (data, variables) => {
// Invalidate orders list for this tenant
queryClient.invalidateQueries({
queryKey: ordersKeys.orders(),
predicate: (query) => {
const queryKey = query.queryKey as string[];
return queryKey.includes('list') &&
JSON.stringify(queryKey).includes(variables.tenant_id);
},
});
// Invalidate dashboard
queryClient.invalidateQueries({
queryKey: ordersKeys.dashboard(variables.tenant_id),
});
// Add the new order to cache
queryClient.setQueryData(
ordersKeys.order(variables.tenant_id, data.id),
data
);
},
...options,
});
};
export const useUpdateOrder = (
options?: UseMutationOptions<OrderResponse, ApiError, { tenantId: string; orderId: string; data: OrderUpdate }>
) => {
const queryClient = useQueryClient();
return useMutation<OrderResponse, ApiError, { tenantId: string; orderId: string; data: OrderUpdate }>({
mutationFn: ({ tenantId, orderId, data }) => OrdersService.updateOrder(tenantId, orderId, data),
onSuccess: (data, variables) => {
// Update the specific order in cache
queryClient.setQueryData(
ordersKeys.order(variables.tenantId, variables.orderId),
data
);
// Invalidate orders list for this tenant
queryClient.invalidateQueries({
queryKey: ordersKeys.orders(),
predicate: (query) => {
const queryKey = query.queryKey as string[];
return queryKey.includes('list') &&
JSON.stringify(queryKey).includes(variables.tenantId);
},
});
// Invalidate dashboard
queryClient.invalidateQueries({
queryKey: ordersKeys.dashboard(variables.tenantId),
});
},
...options,
});
};
export const useUpdateOrderStatus = (
options?: UseMutationOptions<OrderResponse, ApiError, UpdateOrderStatusParams>
) => {
const queryClient = useQueryClient();
return useMutation<OrderResponse, ApiError, UpdateOrderStatusParams>({
mutationFn: (params: UpdateOrderStatusParams) => OrdersService.updateOrderStatus(params),
onSuccess: (data, variables) => {
// Update the specific order in cache
queryClient.setQueryData(
ordersKeys.order(variables.tenant_id, variables.order_id),
data
);
// Invalidate orders list for this tenant
queryClient.invalidateQueries({
queryKey: ordersKeys.orders(),
predicate: (query) => {
const queryKey = query.queryKey as string[];
return queryKey.includes('list') &&
JSON.stringify(queryKey).includes(variables.tenant_id);
},
});
// Invalidate dashboard
queryClient.invalidateQueries({
queryKey: ordersKeys.dashboard(variables.tenant_id),
});
},
...options,
});
};
// ===== Customer Mutations =====
export const useCreateCustomer = (
options?: UseMutationOptions<CustomerResponse, ApiError, CustomerCreate>
) => {
const queryClient = useQueryClient();
return useMutation<CustomerResponse, ApiError, CustomerCreate>({
mutationFn: (customerData: CustomerCreate) => OrdersService.createCustomer(customerData),
onSuccess: (data, variables) => {
// Invalidate all customer list queries for this tenant
// This will match any query with ['orders', 'customers', 'list', ...]
// refetchType: 'active' forces immediate refetch of mounted queries
queryClient.invalidateQueries({
queryKey: ordersKeys.customers(),
refetchType: 'active',
});
// Add the new customer to cache
queryClient.setQueryData(
ordersKeys.customer(variables.tenant_id, data.id),
data
);
// Invalidate dashboard (for customer metrics)
queryClient.invalidateQueries({
queryKey: ordersKeys.dashboard(variables.tenant_id),
refetchType: 'active',
});
},
...options,
});
};
export const useUpdateCustomer = (
options?: UseMutationOptions<CustomerResponse, ApiError, { tenantId: string; customerId: string; data: CustomerUpdate }>
) => {
const queryClient = useQueryClient();
return useMutation<CustomerResponse, ApiError, { tenantId: string; customerId: string; data: CustomerUpdate }>({
mutationFn: ({ tenantId, customerId, data }) => OrdersService.updateCustomer(tenantId, customerId, data),
onSuccess: (data, variables) => {
// Update the specific customer in cache
queryClient.setQueryData(
ordersKeys.customer(variables.tenantId, variables.customerId),
data
);
// Invalidate all customer list queries
// This will match any query with ['orders', 'customers', 'list', ...]
// refetchType: 'active' forces immediate refetch of mounted queries
queryClient.invalidateQueries({
queryKey: ordersKeys.customers(),
refetchType: 'active',
});
// Invalidate dashboard (for customer metrics)
queryClient.invalidateQueries({
queryKey: ordersKeys.dashboard(variables.tenantId),
refetchType: 'active',
});
},
...options,
});
};
// ===== Utility Functions =====
export const useInvalidateOrders = () => {
const queryClient = useQueryClient();
return {
invalidateAllOrders: (tenantId?: string) => {
if (tenantId) {
queryClient.invalidateQueries({
queryKey: ordersKeys.all,
predicate: (query) => {
return JSON.stringify(query.queryKey).includes(tenantId);
},
});
} else {
queryClient.invalidateQueries({ queryKey: ordersKeys.all });
}
},
invalidateOrdersList: (tenantId: string) => {
queryClient.invalidateQueries({
queryKey: ordersKeys.orders(),
predicate: (query) => {
const queryKey = query.queryKey as string[];
return queryKey.includes('list') &&
JSON.stringify(queryKey).includes(tenantId);
},
});
},
invalidateCustomersList: (tenantId: string) => {
queryClient.invalidateQueries({
queryKey: ordersKeys.customers(),
predicate: (query) => {
const queryKey = query.queryKey as string[];
return queryKey.includes('list') &&
JSON.stringify(queryKey).includes(tenantId);
},
});
},
invalidateDashboard: (tenantId: string) => {
queryClient.invalidateQueries({
queryKey: ordersKeys.dashboard(tenantId),
});
},
};
};

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,687 @@
/**
* POS React Query hooks
* Provides data fetching and mutation hooks for POS operations
*/
import { useMutation, useQuery, useQueryClient, UseQueryOptions, UseMutationOptions } from '@tanstack/react-query';
import { posService } from '../services/pos';
import type {
POSConfiguration,
POSTransaction,
POSSyncLog,
POSWebhookLog,
GetPOSConfigurationsRequest,
GetPOSConfigurationsResponse,
CreatePOSConfigurationRequest,
CreatePOSConfigurationResponse,
GetPOSConfigurationRequest,
GetPOSConfigurationResponse,
UpdatePOSConfigurationRequest,
UpdatePOSConfigurationResponse,
DeletePOSConfigurationRequest,
DeletePOSConfigurationResponse,
TestPOSConnectionRequest,
TestPOSConnectionResponse,
GetSupportedPOSSystemsResponse,
POSSystem,
} from '../types/pos';
import { ApiError } from '../client';
// ============================================================================
// QUERY KEYS
// ============================================================================
export const posKeys = {
all: ['pos'] as const,
// Configurations
configurations: () => [...posKeys.all, 'configurations'] as const,
configurationsList: (tenantId: string, filters?: { pos_system?: POSSystem; is_active?: boolean }) =>
[...posKeys.configurations(), 'list', tenantId, filters] as const,
configuration: (tenantId: string, configId: string) =>
[...posKeys.configurations(), 'detail', tenantId, configId] as const,
// Supported Systems
supportedSystems: () => [...posKeys.all, 'supported-systems'] as const,
// Transactions
transactions: () => [...posKeys.all, 'transactions'] as const,
transactionsList: (tenantId: string, filters?: any) =>
[...posKeys.transactions(), 'list', tenantId, filters] as const,
transaction: (tenantId: string, transactionId: string) =>
[...posKeys.transactions(), 'detail', tenantId, transactionId] as const,
// Sync Logs
syncLogs: () => [...posKeys.all, 'sync-logs'] as const,
syncLogsList: (tenantId: string, filters?: any) =>
[...posKeys.syncLogs(), 'list', tenantId, filters] as const,
// Webhook Logs
webhookLogs: () => [...posKeys.all, 'webhook-logs'] as const,
webhookLogsList: (tenantId: string, filters?: any) =>
[...posKeys.webhookLogs(), 'list', tenantId, filters] as const,
} as const;
// ============================================================================
// CONFIGURATION QUERIES
// ============================================================================
/**
* Get POS configurations for a tenant
*/
export const usePOSConfigurations = (
params: GetPOSConfigurationsRequest,
options?: Omit<UseQueryOptions<GetPOSConfigurationsResponse, ApiError>, 'queryKey' | 'queryFn'>
) => {
return useQuery<GetPOSConfigurationsResponse, ApiError>({
queryKey: posKeys.configurationsList(params.tenant_id, {
pos_system: params.pos_system,
is_active: params.is_active
}),
queryFn: () => posService.getPOSConfigurations(params),
enabled: !!params.tenant_id,
staleTime: 2 * 60 * 1000, // 2 minutes
...options,
});
};
/**
* Get a specific POS configuration
*/
export const usePOSConfiguration = (
params: GetPOSConfigurationRequest,
options?: Omit<UseQueryOptions<GetPOSConfigurationResponse, ApiError>, 'queryKey' | 'queryFn'>
) => {
return useQuery<GetPOSConfigurationResponse, ApiError>({
queryKey: posKeys.configuration(params.tenant_id, params.config_id),
queryFn: () => posService.getPOSConfiguration(params),
enabled: !!params.tenant_id && !!params.config_id,
staleTime: 5 * 60 * 1000, // 5 minutes
...options,
});
};
/**
* Get supported POS systems
*/
export const useSupportedPOSSystems = (
options?: Omit<UseQueryOptions<GetSupportedPOSSystemsResponse, ApiError>, 'queryKey' | 'queryFn'>
) => {
return useQuery<GetSupportedPOSSystemsResponse, ApiError>({
queryKey: posKeys.supportedSystems(),
queryFn: () => posService.getSupportedPOSSystems(),
staleTime: 30 * 60 * 1000, // 30 minutes - this data rarely changes
...options,
});
};
// ============================================================================
// CONFIGURATION MUTATIONS
// ============================================================================
/**
* Create a new POS configuration
*/
export const useCreatePOSConfiguration = (
options?: UseMutationOptions<CreatePOSConfigurationResponse, ApiError, CreatePOSConfigurationRequest>
) => {
const queryClient = useQueryClient();
return useMutation<CreatePOSConfigurationResponse, ApiError, CreatePOSConfigurationRequest>({
mutationFn: (params) => posService.createPOSConfiguration(params),
onSuccess: (data, variables) => {
// Invalidate and refetch configurations list
queryClient.invalidateQueries({
queryKey: posKeys.configurationsList(variables.tenant_id)
});
// If we have the created configuration, add it to the cache
if (data.configuration) {
queryClient.setQueryData(
posKeys.configuration(variables.tenant_id, data.id),
{ configuration: data.configuration }
);
}
},
...options,
});
};
/**
* Update a POS configuration
*/
export const useUpdatePOSConfiguration = (
options?: UseMutationOptions<UpdatePOSConfigurationResponse, ApiError, UpdatePOSConfigurationRequest>
) => {
const queryClient = useQueryClient();
return useMutation<UpdatePOSConfigurationResponse, ApiError, UpdatePOSConfigurationRequest>({
mutationFn: (params) => posService.updatePOSConfiguration(params),
onSuccess: (data, variables) => {
// Invalidate and refetch configurations list
queryClient.invalidateQueries({
queryKey: posKeys.configurationsList(variables.tenant_id)
});
// Update the specific configuration cache if we have the updated data
if (data.configuration) {
queryClient.setQueryData(
posKeys.configuration(variables.tenant_id, variables.config_id),
{ configuration: data.configuration }
);
} else {
// Invalidate the specific configuration to refetch
queryClient.invalidateQueries({
queryKey: posKeys.configuration(variables.tenant_id, variables.config_id)
});
}
},
...options,
});
};
/**
* Delete a POS configuration
*/
export const useDeletePOSConfiguration = (
options?: UseMutationOptions<DeletePOSConfigurationResponse, ApiError, DeletePOSConfigurationRequest>
) => {
const queryClient = useQueryClient();
return useMutation<DeletePOSConfigurationResponse, ApiError, DeletePOSConfigurationRequest>({
mutationFn: (params) => posService.deletePOSConfiguration(params),
onSuccess: (data, variables) => {
// Remove from configurations list cache
queryClient.invalidateQueries({
queryKey: posKeys.configurationsList(variables.tenant_id)
});
// Remove the specific configuration from cache
queryClient.removeQueries({
queryKey: posKeys.configuration(variables.tenant_id, variables.config_id)
});
},
...options,
});
};
/**
* Test POS connection
*/
export const useTestPOSConnection = (
options?: UseMutationOptions<TestPOSConnectionResponse, ApiError, TestPOSConnectionRequest>
) => {
const queryClient = useQueryClient();
return useMutation<TestPOSConnectionResponse, ApiError, TestPOSConnectionRequest>({
mutationFn: (params) => posService.testPOSConnection(params),
onSuccess: (data, variables) => {
// Invalidate the configurations list to refresh connection status
queryClient.invalidateQueries({
queryKey: posKeys.configurationsList(variables.tenant_id)
});
// Invalidate the specific configuration to refresh connection status
queryClient.invalidateQueries({
queryKey: posKeys.configuration(variables.tenant_id, variables.config_id)
});
},
...options,
});
};
// ============================================================================
// TRANSACTION QUERIES
// ============================================================================
/**
* Get POS transactions for a tenant (Updated to match backend)
*/
export const usePOSTransactions = (
params: {
tenant_id: string;
pos_system?: string;
start_date?: string;
end_date?: string;
status?: string;
is_synced?: boolean;
limit?: number;
offset?: number;
},
options?: Omit<UseQueryOptions<{
transactions: POSTransaction[];
total: number;
has_more: boolean;
summary: {
total_amount: number;
transaction_count: number;
sync_status: {
synced: number;
pending: number;
failed: number;
};
};
}, ApiError>, 'queryKey' | 'queryFn'>
) => {
return useQuery({
queryKey: posKeys.transactionsList(params.tenant_id, params),
queryFn: () => posService.getPOSTransactions(params),
enabled: !!params.tenant_id,
staleTime: 30 * 1000, // 30 seconds - transaction data should be fresh
...options,
});
};
/**
* Get a specific POS transaction
*/
export const usePOSTransaction = (
params: {
tenant_id: string;
transaction_id: string;
},
options?: Omit<UseQueryOptions<POSTransaction, ApiError>, 'queryKey' | 'queryFn'>
) => {
return useQuery({
queryKey: posKeys.transaction(params.tenant_id, params.transaction_id),
queryFn: () => posService.getPOSTransaction(params),
enabled: !!params.tenant_id && !!params.transaction_id,
staleTime: 5 * 60 * 1000, // 5 minutes
...options,
});
};
/**
* Get POS transactions dashboard summary
*/
export const usePOSTransactionsDashboard = (
params: {
tenant_id: string;
},
options?: Omit<UseQueryOptions<{
total_transactions_today: number;
total_transactions_this_week: number;
total_transactions_this_month: number;
revenue_today: number;
revenue_this_week: number;
revenue_this_month: number;
average_transaction_value: number;
status_breakdown: Record<string, number>;
payment_method_breakdown: Record<string, number>;
sync_status: {
synced: number;
pending: number;
failed: number;
last_sync_at?: string;
};
}, ApiError>, 'queryKey' | 'queryFn'>
) => {
return useQuery({
queryKey: [...posKeys.transactions(), 'dashboard', params.tenant_id],
queryFn: () => posService.getPOSTransactionsDashboard(params),
enabled: !!params.tenant_id,
staleTime: 30 * 1000, // 30 seconds
...options,
});
};
// ============================================================================
// SYNC OPERATIONS
// ============================================================================
/**
* Trigger manual sync
*/
export const useTriggerManualSync = (
options?: UseMutationOptions<
{
sync_id: string;
message: string;
status: string;
sync_type: string;
data_types: string[];
estimated_duration: string;
},
ApiError,
{
tenant_id: string;
config_id: string;
sync_type?: 'full' | 'incremental';
data_types?: string[];
from_date?: string;
to_date?: string;
}
>
) => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (params) => posService.triggerManualSync(params),
onSuccess: (data, variables) => {
// Invalidate sync logs to show the new sync
queryClient.invalidateQueries({
queryKey: posKeys.syncLogsList(variables.tenant_id)
});
// Invalidate configurations to update last sync info
queryClient.invalidateQueries({
queryKey: posKeys.configurationsList(variables.tenant_id)
});
},
...options,
});
};
/**
* Get sync status for a configuration
*/
export const usePOSSyncStatus = (
params: {
tenant_id: string;
config_id: string;
limit?: number;
},
options?: Omit<UseQueryOptions<{
current_sync: any;
last_successful_sync: any;
recent_syncs: any[];
sync_health: {
status: string;
success_rate: number;
average_duration_minutes: number;
last_error?: string;
};
}, ApiError>, 'queryKey' | 'queryFn'>
) => {
return useQuery({
queryKey: [...posKeys.configurations(), 'sync-status', params.tenant_id, params.config_id],
queryFn: () => posService.getSyncStatus(params),
enabled: !!params.tenant_id && !!params.config_id,
staleTime: 30 * 1000, // 30 seconds - sync status should be fresh
...options,
});
};
/**
* Get detailed sync logs for a configuration
*/
export const useDetailedSyncLogs = (
params: {
tenant_id: string;
config_id: string;
limit?: number;
offset?: number;
status?: string;
sync_type?: string;
data_type?: string;
},
options?: Omit<UseQueryOptions<{
logs: any[];
total: number;
has_more: boolean;
}, ApiError>, 'queryKey' | 'queryFn'>
) => {
return useQuery({
queryKey: [...posKeys.syncLogs(), 'detailed', params.tenant_id, params.config_id, params],
queryFn: () => posService.getDetailedSyncLogs(params),
enabled: !!params.tenant_id && !!params.config_id,
staleTime: 30 * 1000, // 30 seconds
...options,
});
};
/**
* Sync single transaction
*/
export const useSyncSingleTransaction = (
options?: UseMutationOptions<
{ message: string; transaction_id: string; sync_status: string; sales_record_id: string },
ApiError,
{ tenant_id: string; transaction_id: string; force?: boolean }
>
) => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (params) => posService.syncSingleTransaction(params),
onSuccess: (data, variables) => {
// Invalidate transactions list to update sync status
queryClient.invalidateQueries({
queryKey: posKeys.transactionsList(variables.tenant_id)
});
// Invalidate specific transaction
queryClient.invalidateQueries({
queryKey: posKeys.transaction(variables.tenant_id, variables.transaction_id)
});
},
...options,
});
};
/**
* Get sync performance analytics
*/
export const usePOSSyncAnalytics = (
params: {
tenant_id: string;
days?: number;
},
options?: Omit<UseQueryOptions<{
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: any[];
error_trends: any[];
};
}, ApiError>, 'queryKey' | 'queryFn'>
) => {
return useQuery({
queryKey: [...posKeys.all, 'analytics', params.tenant_id, params.days],
queryFn: () => posService.getSyncAnalytics(params),
enabled: !!params.tenant_id,
staleTime: 5 * 60 * 1000, // 5 minutes - analytics don't change frequently
...options,
});
};
/**
* Resync failed transactions
*/
export const useResyncFailedTransactions = (
options?: UseMutationOptions<
{ message: string; job_id: string; scope: string; estimated_transactions: number },
ApiError,
{ tenant_id: string; days_back?: number }
>
) => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (params) => posService.resyncFailedTransactions(params),
onSuccess: (data, variables) => {
// Invalidate sync logs and analytics
queryClient.invalidateQueries({
queryKey: posKeys.syncLogsList(variables.tenant_id)
});
queryClient.invalidateQueries({
queryKey: [...posKeys.all, 'analytics', variables.tenant_id]
});
},
...options,
});
};
/**
* Get sync logs
*/
export const usePOSSyncLogs = (
params: {
tenant_id: string;
config_id?: string;
status?: string;
limit?: number;
offset?: number;
},
options?: Omit<UseQueryOptions<{
sync_logs: POSSyncLog[];
total: number;
has_more: boolean;
}, ApiError>, 'queryKey' | 'queryFn'>
) => {
return useQuery({
queryKey: posKeys.syncLogsList(params.tenant_id, params),
queryFn: () => posService.getSyncLogs(params),
enabled: !!params.tenant_id,
staleTime: 30 * 1000, // 30 seconds
...options,
});
};
// ============================================================================
// WEBHOOK LOGS
// ============================================================================
/**
* Get webhook logs
*/
export const usePOSWebhookLogs = (
params: {
tenant_id: string;
pos_system?: POSSystem;
status?: string;
limit?: number;
offset?: number;
},
options?: Omit<UseQueryOptions<{
webhook_logs: POSWebhookLog[];
total: number;
has_more: boolean;
}, ApiError>, 'queryKey' | 'queryFn'>
) => {
return useQuery({
queryKey: posKeys.webhookLogsList(params.tenant_id, params),
queryFn: () => posService.getWebhookLogs(params),
enabled: !!params.tenant_id,
staleTime: 30 * 1000, // 30 seconds
...options,
});
};
/**
* Get webhook status for a POS system
*/
export const useWebhookStatus = (
pos_system: POSSystem,
options?: Omit<UseQueryOptions<{
pos_system: string;
status: string;
endpoint: string;
supported_events: {
events: string[];
format: string;
authentication: string;
};
last_received?: string;
total_received: number;
}, ApiError>, 'queryKey' | 'queryFn'>
) => {
return useQuery({
queryKey: [...posKeys.webhookLogs(), 'status', pos_system],
queryFn: () => posService.getWebhookStatus(pos_system),
enabled: !!pos_system,
staleTime: 5 * 60 * 1000, // 5 minutes - webhook status doesn't change often
...options,
});
};
// ============================================================================
// UTILITY HOOKS
// ============================================================================
/**
* Hook to get POS service utility functions
*/
export const usePOSUtils = () => {
return {
formatPrice: posService.formatPrice,
getPOSSystemDisplayName: posService.getPOSSystemDisplayName,
getConnectionStatusColor: posService.getConnectionStatusColor,
getSyncStatusColor: posService.getSyncStatusColor,
formatSyncInterval: posService.formatSyncInterval,
validateCredentials: posService.validateCredentials,
};
};
// ============================================================================
// COMPOSITE HOOKS (Convenience)
// ============================================================================
/**
* Hook that combines configurations and supported systems for the configuration UI
*/
export const usePOSConfigurationData = (tenantId: string) => {
const configurationsQuery = usePOSConfigurations(
{ tenant_id: tenantId },
{ enabled: !!tenantId }
);
const supportedSystemsQuery = useSupportedPOSSystems();
return {
configurations: configurationsQuery.data?.configurations || [],
supportedSystems: supportedSystemsQuery.data?.systems || [],
isLoading: configurationsQuery.isLoading || supportedSystemsQuery.isLoading,
error: configurationsQuery.error || supportedSystemsQuery.error,
refetch: () => {
configurationsQuery.refetch();
supportedSystemsQuery.refetch();
},
};
};
/**
* Hook for POS configuration management with all CRUD operations
*/
export const usePOSConfigurationManager = (tenantId: string) => {
const utils = usePOSUtils();
const createMutation = useCreatePOSConfiguration();
const updateMutation = useUpdatePOSConfiguration();
const deleteMutation = useDeletePOSConfiguration();
const testConnectionMutation = useTestPOSConnection();
return {
// Utility functions
...utils,
// Mutations
createConfiguration: createMutation.mutateAsync,
updateConfiguration: updateMutation.mutateAsync,
deleteConfiguration: deleteMutation.mutateAsync,
testConnection: testConnectionMutation.mutateAsync,
// Mutation 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,
};
};

View File

@@ -0,0 +1,495 @@
/**
* Procurement React Query hooks
* All hooks use the ProcurementService which connects to the standalone Procurement Service backend
*/
import {
useMutation,
useQuery,
useQueryClient,
UseQueryOptions,
UseMutationOptions,
} from '@tanstack/react-query';
import { ProcurementService } from '../services/procurement-service';
import {
// Response types
ProcurementPlanResponse,
ProcurementRequirementResponse,
ProcurementDashboardData,
ProcurementTrendsData,
PaginatedProcurementPlans,
GeneratePlanResponse,
CreatePOsResult,
// Request types
GeneratePlanRequest,
AutoGenerateProcurementRequest,
AutoGenerateProcurementResponse,
LinkRequirementToPORequest,
UpdateDeliveryStatusRequest,
// Query param types
GetProcurementPlansParams,
GetPlanRequirementsParams,
UpdatePlanStatusParams,
} from '../types/procurement';
import { ApiError } from '../client/apiClient';
// ===================================================================
// QUERY KEYS
// ===================================================================
export const procurementKeys = {
all: ['procurement'] as const,
// Analytics & Dashboard
analytics: (tenantId: string) => [...procurementKeys.all, 'analytics', tenantId] as const,
trends: (tenantId: string, days: number) => [...procurementKeys.all, 'trends', tenantId, days] as const,
// Plans
plans: () => [...procurementKeys.all, 'plans'] as const,
plansList: (params: GetProcurementPlansParams) => [...procurementKeys.plans(), 'list', params] as const,
plan: (tenantId: string, planId: string) => [...procurementKeys.plans(), 'detail', tenantId, planId] as const,
planByDate: (tenantId: string, date: string) => [...procurementKeys.plans(), 'by-date', tenantId, date] as const,
currentPlan: (tenantId: string) => [...procurementKeys.plans(), 'current', tenantId] as const,
// Requirements
requirements: () => [...procurementKeys.all, 'requirements'] as const,
planRequirements: (params: GetPlanRequirementsParams) =>
[...procurementKeys.requirements(), 'plan', params] as const,
criticalRequirements: (tenantId: string) =>
[...procurementKeys.requirements(), 'critical', tenantId] as const,
} as const;
// ===================================================================
// ANALYTICS & DASHBOARD QUERIES
// ===================================================================
/**
* Get procurement analytics dashboard data
*/
export const useProcurementDashboard = (
tenantId: string,
options?: Omit<UseQueryOptions<ProcurementDashboardData, ApiError>, 'queryKey' | 'queryFn'>
) => {
return useQuery<ProcurementDashboardData, ApiError>({
queryKey: procurementKeys.analytics(tenantId),
queryFn: () => ProcurementService.getProcurementAnalytics(tenantId),
staleTime: 2 * 60 * 1000, // 2 minutes
enabled: !!tenantId,
...options,
});
};
/**
* Get procurement time-series trends for charts
*/
export const useProcurementTrends = (
tenantId: string,
days: number = 7,
options?: Omit<UseQueryOptions<ProcurementTrendsData, ApiError>, 'queryKey' | 'queryFn'>
) => {
return useQuery<ProcurementTrendsData, ApiError>({
queryKey: procurementKeys.trends(tenantId, days),
queryFn: () => ProcurementService.getProcurementTrends(tenantId, days),
staleTime: 5 * 60 * 1000, // 5 minutes
enabled: !!tenantId,
...options,
});
};
// ===================================================================
// PROCUREMENT PLAN QUERIES
// ===================================================================
/**
* Get list of procurement plans with pagination and filtering
*/
export const useProcurementPlans = (
params: GetProcurementPlansParams,
options?: Omit<UseQueryOptions<PaginatedProcurementPlans, ApiError>, 'queryKey' | 'queryFn'>
) => {
return useQuery<PaginatedProcurementPlans, ApiError>({
queryKey: procurementKeys.plansList(params),
queryFn: () => ProcurementService.getProcurementPlans(params),
staleTime: 5 * 60 * 1000, // 5 minutes
enabled: !!params.tenant_id,
...options,
});
};
/**
* Get a single procurement plan by ID
*/
export const useProcurementPlan = (
tenantId: string,
planId: string,
options?: Omit<UseQueryOptions<ProcurementPlanResponse | null, ApiError>, 'queryKey' | 'queryFn'>
) => {
return useQuery<ProcurementPlanResponse | null, ApiError>({
queryKey: procurementKeys.plan(tenantId, planId),
queryFn: () => ProcurementService.getProcurementPlanById(tenantId, planId),
staleTime: 2 * 60 * 1000, // 2 minutes
enabled: !!tenantId && !!planId,
...options,
});
};
/**
* Get procurement plan for a specific date
*/
export const useProcurementPlanByDate = (
tenantId: string,
planDate: string,
options?: Omit<UseQueryOptions<ProcurementPlanResponse | null, ApiError>, 'queryKey' | 'queryFn'>
) => {
return useQuery<ProcurementPlanResponse | null, ApiError>({
queryKey: procurementKeys.planByDate(tenantId, planDate),
queryFn: () => ProcurementService.getProcurementPlanByDate(tenantId, planDate),
staleTime: 2 * 60 * 1000, // 2 minutes
enabled: !!tenantId && !!planDate,
...options,
});
};
/**
* Get the current day's procurement plan
*/
export const useCurrentProcurementPlan = (
tenantId: string,
options?: Omit<UseQueryOptions<ProcurementPlanResponse | null, ApiError>, 'queryKey' | 'queryFn'>
) => {
return useQuery<ProcurementPlanResponse | null, ApiError>({
queryKey: procurementKeys.currentPlan(tenantId),
queryFn: () => ProcurementService.getCurrentProcurementPlan(tenantId),
staleTime: 1 * 60 * 1000, // 1 minute
enabled: !!tenantId,
...options,
});
};
// ===================================================================
// PROCUREMENT REQUIREMENTS QUERIES
// ===================================================================
/**
* Get requirements for a specific procurement plan
*/
export const usePlanRequirements = (
params: GetPlanRequirementsParams,
options?: Omit<UseQueryOptions<ProcurementRequirementResponse[], ApiError>, 'queryKey' | 'queryFn'>
) => {
return useQuery<ProcurementRequirementResponse[], ApiError>({
queryKey: procurementKeys.planRequirements(params),
queryFn: () => ProcurementService.getPlanRequirements(params),
staleTime: 2 * 60 * 1000, // 2 minutes
enabled: !!params.tenant_id && !!params.plan_id,
...options,
});
};
/**
* Get critical requirements across all plans
*/
export const useCriticalRequirements = (
tenantId: string,
options?: Omit<UseQueryOptions<ProcurementRequirementResponse[], ApiError>, 'queryKey' | 'queryFn'>
) => {
return useQuery<ProcurementRequirementResponse[], ApiError>({
queryKey: procurementKeys.criticalRequirements(tenantId),
queryFn: () => ProcurementService.getCriticalRequirements(tenantId),
staleTime: 1 * 60 * 1000, // 1 minute
enabled: !!tenantId,
...options,
});
};
// ===================================================================
// PROCUREMENT PLAN MUTATIONS
// ===================================================================
/**
* Generate a new procurement plan (manual/UI-driven)
*/
export const useGenerateProcurementPlan = (
options?: UseMutationOptions<GeneratePlanResponse, ApiError, { tenantId: string; request: GeneratePlanRequest }>
) => {
const queryClient = useQueryClient();
return useMutation<GeneratePlanResponse, ApiError, { tenantId: string; request: GeneratePlanRequest }>({
mutationFn: ({ tenantId, request }) => ProcurementService.generateProcurementPlan(tenantId, request),
onSuccess: (data, variables) => {
// Invalidate all procurement queries for this tenant
queryClient.invalidateQueries({
queryKey: procurementKeys.all,
predicate: (query) => {
return JSON.stringify(query.queryKey).includes(variables.tenantId);
},
});
// If plan was generated successfully, cache it
if (data.success && data.plan) {
queryClient.setQueryData(procurementKeys.plan(variables.tenantId, data.plan.id), data.plan);
}
},
...options,
});
};
/**
* Auto-generate procurement plan from forecast data (Orchestrator integration)
*/
export const useAutoGenerateProcurement = (
options?: UseMutationOptions<
AutoGenerateProcurementResponse,
ApiError,
{ tenantId: string; request: AutoGenerateProcurementRequest }
>
) => {
const queryClient = useQueryClient();
return useMutation<
AutoGenerateProcurementResponse,
ApiError,
{ tenantId: string; request: AutoGenerateProcurementRequest }
>({
mutationFn: ({ tenantId, request }) => ProcurementService.autoGenerateProcurement(tenantId, request),
onSuccess: (data, variables) => {
// Invalidate all procurement queries
queryClient.invalidateQueries({
queryKey: procurementKeys.all,
predicate: (query) => {
return JSON.stringify(query.queryKey).includes(variables.tenantId);
},
});
// If plan was created successfully, cache it
if (data.success && data.plan_id) {
queryClient.invalidateQueries({
queryKey: procurementKeys.currentPlan(variables.tenantId),
});
}
},
...options,
});
};
/**
* Update procurement plan status
*/
export const useUpdateProcurementPlanStatus = (
options?: UseMutationOptions<ProcurementPlanResponse, ApiError, UpdatePlanStatusParams>
) => {
const queryClient = useQueryClient();
return useMutation<ProcurementPlanResponse, ApiError, UpdatePlanStatusParams>({
mutationFn: (params) => ProcurementService.updateProcurementPlanStatus(params),
onSuccess: (data, variables) => {
// Update the specific plan in cache
queryClient.setQueryData(procurementKeys.plan(variables.tenant_id, variables.plan_id), data);
// Invalidate plans list
queryClient.invalidateQueries({
queryKey: procurementKeys.plans(),
predicate: (query) => {
return JSON.stringify(query.queryKey).includes(variables.tenant_id);
},
});
},
...options,
});
};
/**
* Recalculate an existing procurement plan
*/
export const useRecalculateProcurementPlan = (
options?: UseMutationOptions<GeneratePlanResponse, ApiError, { tenantId: string; planId: string }>
) => {
const queryClient = useQueryClient();
return useMutation<GeneratePlanResponse, ApiError, { tenantId: string; planId: string }>({
mutationFn: ({ tenantId, planId }) => ProcurementService.recalculateProcurementPlan(tenantId, planId),
onSuccess: (data, variables) => {
if (data.plan) {
// Update the specific plan in cache
queryClient.setQueryData(procurementKeys.plan(variables.tenantId, variables.planId), data.plan);
}
// Invalidate plans list and dashboard
queryClient.invalidateQueries({
queryKey: procurementKeys.all,
predicate: (query) => {
return JSON.stringify(query.queryKey).includes(variables.tenantId);
},
});
},
...options,
});
};
/**
* Approve a procurement plan
*/
export const useApproveProcurementPlan = (
options?: UseMutationOptions<
ProcurementPlanResponse,
ApiError,
{ tenantId: string; planId: string; approval_notes?: string }
>
) => {
const queryClient = useQueryClient();
return useMutation<
ProcurementPlanResponse,
ApiError,
{ tenantId: string; planId: string; approval_notes?: string }
>({
mutationFn: ({ tenantId, planId, approval_notes }) =>
ProcurementService.approveProcurementPlan(tenantId, planId, { approval_notes }),
onSuccess: (data, variables) => {
// Update the specific plan in cache
queryClient.setQueryData(procurementKeys.plan(variables.tenantId, variables.planId), data);
// Invalidate plans list and dashboard
queryClient.invalidateQueries({
queryKey: procurementKeys.all,
predicate: (query) => {
return JSON.stringify(query.queryKey).includes(variables.tenantId);
},
});
},
...options,
});
};
/**
* Reject a procurement plan
*/
export const useRejectProcurementPlan = (
options?: UseMutationOptions<
ProcurementPlanResponse,
ApiError,
{ tenantId: string; planId: string; rejection_notes?: string }
>
) => {
const queryClient = useQueryClient();
return useMutation<
ProcurementPlanResponse,
ApiError,
{ tenantId: string; planId: string; rejection_notes?: string }
>({
mutationFn: ({ tenantId, planId, rejection_notes }) =>
ProcurementService.rejectProcurementPlan(tenantId, planId, { rejection_notes }),
onSuccess: (data, variables) => {
// Update the specific plan in cache
queryClient.setQueryData(procurementKeys.plan(variables.tenantId, variables.planId), data);
// Invalidate plans list and dashboard
queryClient.invalidateQueries({
queryKey: procurementKeys.all,
predicate: (query) => {
return JSON.stringify(query.queryKey).includes(variables.tenantId);
},
});
},
...options,
});
};
// ===================================================================
// PURCHASE ORDER MUTATIONS
// ===================================================================
/**
* Create purchase orders from procurement plan
*/
export const useCreatePurchaseOrdersFromPlan = (
options?: UseMutationOptions<CreatePOsResult, ApiError, { tenantId: string; planId: string; autoApprove?: boolean }>
) => {
const queryClient = useQueryClient();
return useMutation<CreatePOsResult, ApiError, { tenantId: string; planId: string; autoApprove?: boolean }>({
mutationFn: ({ tenantId, planId, autoApprove = false }) =>
ProcurementService.createPurchaseOrdersFromPlan(tenantId, planId, autoApprove),
onSuccess: (data, variables) => {
// Invalidate procurement plan to refresh requirements status
queryClient.invalidateQueries({
queryKey: procurementKeys.plan(variables.tenantId, variables.planId),
});
// Invalidate plan requirements
queryClient.invalidateQueries({
queryKey: procurementKeys.requirements(),
predicate: (query) => {
return JSON.stringify(query.queryKey).includes(variables.planId);
},
});
},
...options,
});
};
/**
* Link a procurement requirement to a purchase order
*/
export const useLinkRequirementToPurchaseOrder = (
options?: UseMutationOptions<
{ success: boolean; message: string; requirement_id: string; purchase_order_id: string },
ApiError,
{ tenantId: string; requirementId: string; request: LinkRequirementToPORequest }
>
) => {
const queryClient = useQueryClient();
return useMutation<
{ success: boolean; message: string; requirement_id: string; purchase_order_id: string },
ApiError,
{ tenantId: string; requirementId: string; request: LinkRequirementToPORequest }
>({
mutationFn: ({ tenantId, requirementId, request }) =>
ProcurementService.linkRequirementToPurchaseOrder(tenantId, requirementId, request),
onSuccess: (data, variables) => {
// Invalidate procurement data to refresh requirements
queryClient.invalidateQueries({
queryKey: procurementKeys.requirements(),
predicate: (query) => {
return JSON.stringify(query.queryKey).includes(variables.tenantId);
},
});
},
...options,
});
};
/**
* Update delivery status for a requirement
*/
export const useUpdateRequirementDeliveryStatus = (
options?: UseMutationOptions<
{ success: boolean; message: string; requirement_id: string; delivery_status: string },
ApiError,
{ tenantId: string; requirementId: string; request: UpdateDeliveryStatusRequest }
>
) => {
const queryClient = useQueryClient();
return useMutation<
{ success: boolean; message: string; requirement_id: string; delivery_status: string },
ApiError,
{ tenantId: string; requirementId: string; request: UpdateDeliveryStatusRequest }
>({
mutationFn: ({ tenantId, requirementId, request }) =>
ProcurementService.updateRequirementDeliveryStatus(tenantId, requirementId, request),
onSuccess: (data, variables) => {
// Invalidate procurement data to refresh requirements
queryClient.invalidateQueries({
queryKey: procurementKeys.requirements(),
predicate: (query) => {
return JSON.stringify(query.queryKey).includes(variables.tenantId);
},
});
},
...options,
});
};

View File

@@ -0,0 +1,282 @@
/**
* Production React Query hooks
*/
import { useMutation, useQuery, useQueryClient, UseQueryOptions, UseMutationOptions } from '@tanstack/react-query';
import { productionService } from '../services/production';
import type {
ProductionBatchCreate,
ProductionBatchStatusUpdate,
ProductionBatchResponse,
ProductionBatchListResponse,
ProductionDashboardSummary,
DailyProductionRequirements,
ProductionScheduleUpdate,
ProductionCapacityStatus,
ProductionYieldMetrics,
} from '../types/production';
import { ApiError } from '../client';
// Query Keys
export const productionKeys = {
all: ['production'] as const,
tenant: (tenantId: string) => [...productionKeys.all, tenantId] as const,
dashboard: (tenantId: string) => [...productionKeys.tenant(tenantId), 'dashboard'] as const,
dailyRequirements: (tenantId: string, date?: string) =>
[...productionKeys.tenant(tenantId), 'daily-requirements', date] as const,
requirements: (tenantId: string, date?: string) =>
[...productionKeys.tenant(tenantId), 'requirements', date] as const,
batches: (tenantId: string) => [...productionKeys.tenant(tenantId), 'batches'] as const,
activeBatches: (tenantId: string) => [...productionKeys.batches(tenantId), 'active'] as const,
batch: (tenantId: string, batchId: string) =>
[...productionKeys.batches(tenantId), batchId] as const,
schedule: (tenantId: string, startDate?: string, endDate?: string) =>
[...productionKeys.tenant(tenantId), 'schedule', startDate, endDate] as const,
capacity: (tenantId: string, date?: string) =>
[...productionKeys.tenant(tenantId), 'capacity', date] as const,
yieldMetrics: (tenantId: string, startDate: string, endDate: string) =>
[...productionKeys.tenant(tenantId), 'yield-metrics', startDate, endDate] as const,
} as const;
// Dashboard Queries
export const useProductionDashboard = (
tenantId: string,
options?: Omit<UseQueryOptions<ProductionDashboardSummary, ApiError>, 'queryKey' | 'queryFn'>
) => {
return useQuery<ProductionDashboardSummary, ApiError>({
queryKey: productionKeys.dashboard(tenantId),
queryFn: () => productionService.getDashboardSummary(tenantId),
enabled: !!tenantId,
staleTime: 30 * 1000, // 30 seconds
refetchInterval: 60 * 1000, // 1 minute
...options,
});
};
export const useDailyProductionRequirements = (
tenantId: string,
date?: string,
options?: Omit<UseQueryOptions<any, ApiError>, 'queryKey' | 'queryFn'>
) => {
return useQuery<any, ApiError>({
queryKey: productionKeys.dailyRequirements(tenantId, date),
queryFn: () => productionService.getDailyProductionPlan(tenantId, date),
enabled: !!tenantId,
staleTime: 5 * 60 * 1000, // 5 minutes
...options,
});
};
export const useProductionRequirements = (
tenantId: string,
date?: string,
options?: Omit<UseQueryOptions<any, ApiError>, 'queryKey' | 'queryFn'>
) => {
const queryDate = date || new Date().toISOString().split('T')[0];
return useQuery<any, ApiError>({
queryKey: productionKeys.requirements(tenantId, date),
queryFn: () => productionService.getProductionRequirements(tenantId, queryDate),
enabled: !!tenantId,
staleTime: 5 * 60 * 1000, // 5 minutes
...options,
});
};
// Batch Queries
export const useActiveBatches = (
tenantId: string,
options?: Omit<UseQueryOptions<ProductionBatchListResponse, ApiError>, 'queryKey' | 'queryFn'>
) => {
return useQuery<ProductionBatchListResponse, ApiError>({
queryKey: productionKeys.activeBatches(tenantId),
queryFn: () => productionService.getBatches(tenantId, {
status: undefined // Get all active statuses
}),
enabled: !!tenantId,
staleTime: 30 * 1000, // 30 seconds
refetchInterval: 60 * 1000, // 1 minute
...options,
});
};
export const useBatchDetails = (
tenantId: string,
batchId: string,
options?: Omit<UseQueryOptions<ProductionBatchResponse, ApiError>, 'queryKey' | 'queryFn'>
) => {
return useQuery<ProductionBatchResponse, ApiError>({
queryKey: productionKeys.batch(tenantId, batchId),
queryFn: () => productionService.getBatch(tenantId, batchId),
enabled: !!tenantId && !!batchId,
staleTime: 30 * 1000, // 30 seconds
...options,
});
};
// Schedule and Capacity Queries
export const useProductionSchedule = (
tenantId: string,
startDate?: string,
endDate?: string,
options?: Omit<UseQueryOptions<any, ApiError>, 'queryKey' | 'queryFn'>
) => {
return useQuery<any, ApiError>({
queryKey: productionKeys.schedule(tenantId, startDate, endDate),
queryFn: () => productionService.getSchedules(tenantId, { start_date: startDate, end_date: endDate }),
enabled: !!tenantId,
staleTime: 5 * 60 * 1000, // 5 minutes
...options,
});
};
export const useCapacityStatus = (
tenantId: string,
date?: string,
options?: Omit<UseQueryOptions<any, ApiError>, 'queryKey' | 'queryFn'>
) => {
return useQuery<any, ApiError>({
queryKey: productionKeys.capacity(tenantId, date),
queryFn: () => date ? productionService.getCapacityByDate(tenantId, date) : productionService.getCapacity(tenantId),
enabled: !!tenantId,
staleTime: 5 * 60 * 1000, // 5 minutes
...options,
});
};
export const useYieldMetrics = (
tenantId: string,
startDate: string,
endDate: string,
options?: Omit<UseQueryOptions<any, ApiError>, 'queryKey' | 'queryFn'>
) => {
return useQuery<any, ApiError>({
queryKey: productionKeys.yieldMetrics(tenantId, startDate, endDate),
queryFn: () => productionService.getYieldTrends(tenantId),
enabled: !!tenantId,
staleTime: 15 * 60 * 1000, // 15 minutes (metrics are less frequently changing)
...options,
});
};
// Mutations
export const useCreateProductionBatch = (
options?: UseMutationOptions<ProductionBatchResponse, ApiError, { tenantId: string; batchData: ProductionBatchCreate }>
) => {
const queryClient = useQueryClient();
return useMutation<ProductionBatchResponse, ApiError, { tenantId: string; batchData: ProductionBatchCreate }>({
mutationFn: ({ tenantId, batchData }) => productionService.createBatch(tenantId, batchData),
onSuccess: (data, { tenantId }) => {
// Invalidate active batches to refresh the list
queryClient.invalidateQueries({ queryKey: productionKeys.activeBatches(tenantId) });
// Invalidate dashboard to update summary
queryClient.invalidateQueries({ queryKey: productionKeys.dashboard(tenantId) });
// Cache the new batch details
queryClient.setQueryData(productionKeys.batch(tenantId, data.id), data);
},
...options,
});
};
export const useUpdateBatchStatus = (
options?: UseMutationOptions<
ProductionBatchResponse,
ApiError,
{ tenantId: string; batchId: string; statusUpdate: ProductionBatchStatusUpdate }
>
) => {
const queryClient = useQueryClient();
return useMutation<
ProductionBatchResponse,
ApiError,
{ tenantId: string; batchId: string; statusUpdate: ProductionBatchStatusUpdate }
>({
mutationFn: ({ tenantId, batchId, statusUpdate }) =>
productionService.updateBatchStatus(tenantId, batchId, statusUpdate),
onSuccess: (data, { tenantId, batchId }) => {
// Update the specific batch data
queryClient.setQueryData(productionKeys.batch(tenantId, batchId), data);
// Invalidate active batches to refresh the list
queryClient.invalidateQueries({ queryKey: productionKeys.activeBatches(tenantId) });
// Invalidate dashboard to update summary
queryClient.invalidateQueries({ queryKey: productionKeys.dashboard(tenantId) });
},
...options,
});
};
// Helper hooks for common use cases
export const useProductionDashboardData = (tenantId: string) => {
const dashboard = useProductionDashboard(tenantId);
const activeBatches = useActiveBatches(tenantId);
const dailyRequirements = useDailyProductionRequirements(tenantId);
return {
dashboard: dashboard.data,
activeBatches: activeBatches.data,
dailyRequirements: dailyRequirements.data,
isLoading: dashboard.isLoading || activeBatches.isLoading || dailyRequirements.isLoading,
error: dashboard.error || activeBatches.error || dailyRequirements.error,
refetch: () => {
dashboard.refetch();
activeBatches.refetch();
dailyRequirements.refetch();
},
};
};
export const useProductionPlanningData = (tenantId: string, date?: string) => {
const schedule = useProductionSchedule(tenantId);
const capacity = useCapacityStatus(tenantId, date);
const requirements = useProductionRequirements(tenantId, date);
return {
schedule: schedule.data,
capacity: capacity.data,
requirements: requirements.data,
isLoading: schedule.isLoading || capacity.isLoading || requirements.isLoading,
error: schedule.error || capacity.error || requirements.error,
refetch: () => {
schedule.refetch();
capacity.refetch();
requirements.refetch();
},
};
};
// ===== Scheduler Mutations =====
/**
* Hook to trigger production scheduler manually (for development/testing)
*/
export const useTriggerProductionScheduler = (
options?: UseMutationOptions<
{ success: boolean; message: string; tenant_id: string },
ApiError,
string
>
) => {
const queryClient = useQueryClient();
return useMutation<
{ success: boolean; message: string; tenant_id: string },
ApiError,
string
>({
mutationFn: (tenantId: string) => productionService.triggerProductionScheduler(tenantId),
onSuccess: (_, tenantId) => {
// Invalidate all production queries for this tenant
queryClient.invalidateQueries({
queryKey: productionKeys.dashboard(tenantId),
});
queryClient.invalidateQueries({
queryKey: productionKeys.batches(tenantId),
});
queryClient.invalidateQueries({
queryKey: productionKeys.activeBatches(tenantId),
});
},
...options,
});
};

View File

@@ -0,0 +1,325 @@
/**
* Purchase Orders React Query hooks
* Handles data fetching and mutations for purchase orders
*/
import { useQuery, useMutation, useQueryClient, UseQueryOptions, UseMutationOptions } from '@tanstack/react-query';
import { ApiError } from '../client/apiClient';
import type {
PurchaseOrderSummary,
PurchaseOrderDetail,
PurchaseOrderSearchParams,
PurchaseOrderUpdateData,
PurchaseOrderCreateData,
PurchaseOrderStatus,
CreateDeliveryInput,
DeliveryResponse
} from '../services/purchase_orders';
import {
listPurchaseOrders,
getPurchaseOrder,
getPendingApprovalPurchaseOrders,
getPurchaseOrdersByStatus,
createPurchaseOrder,
updatePurchaseOrder,
approvePurchaseOrder,
rejectPurchaseOrder,
bulkApprovePurchaseOrders,
deletePurchaseOrder,
createDelivery
} from '../services/purchase_orders';
// Query Keys
export const purchaseOrderKeys = {
all: ['purchase-orders'] as const,
lists: () => [...purchaseOrderKeys.all, 'list'] as const,
list: (tenantId: string, params?: PurchaseOrderSearchParams) =>
[...purchaseOrderKeys.lists(), tenantId, params] as const,
details: () => [...purchaseOrderKeys.all, 'detail'] as const,
detail: (tenantId: string, poId: string) =>
[...purchaseOrderKeys.details(), tenantId, poId] as const,
byStatus: (tenantId: string, status: PurchaseOrderStatus) =>
[...purchaseOrderKeys.lists(), tenantId, 'status', status] as const,
pendingApproval: (tenantId: string) =>
[...purchaseOrderKeys.lists(), tenantId, 'pending-approval'] as const,
} as const;
/**
* Hook to list purchase orders with optional filters
*/
export const usePurchaseOrders = (
tenantId: string,
params?: PurchaseOrderSearchParams,
options?: Omit<UseQueryOptions<PurchaseOrderSummary[], ApiError>, 'queryKey' | 'queryFn'>
) => {
return useQuery<PurchaseOrderSummary[], ApiError>({
queryKey: purchaseOrderKeys.list(tenantId, params),
queryFn: () => listPurchaseOrders(tenantId, params),
enabled: !!tenantId,
staleTime: 30 * 1000, // 30 seconds
...options,
});
};
/**
* Hook to get pending approval purchase orders
*/
export const usePendingApprovalPurchaseOrders = (
tenantId: string,
limit: number = 50,
options?: Omit<UseQueryOptions<PurchaseOrderSummary[], ApiError>, 'queryKey' | 'queryFn'>
) => {
return useQuery<PurchaseOrderSummary[], ApiError>({
queryKey: purchaseOrderKeys.pendingApproval(tenantId),
queryFn: () => getPendingApprovalPurchaseOrders(tenantId, limit),
enabled: !!tenantId,
staleTime: 15 * 1000, // 15 seconds - more frequent for pending approvals
refetchInterval: 30 * 1000, // Auto-refetch every 30 seconds
...options,
});
};
/**
* Hook to get purchase orders by status
*/
export const usePurchaseOrdersByStatus = (
tenantId: string,
status: PurchaseOrderStatus,
limit: number = 50,
options?: Omit<UseQueryOptions<PurchaseOrderSummary[], ApiError>, 'queryKey' | 'queryFn'>
) => {
return useQuery<PurchaseOrderSummary[], ApiError>({
queryKey: purchaseOrderKeys.byStatus(tenantId, status),
queryFn: () => getPurchaseOrdersByStatus(tenantId, status, limit),
enabled: !!tenantId,
staleTime: 30 * 1000, // 30 seconds
...options,
});
};
/**
* Hook to get a single purchase order detail
*/
export const usePurchaseOrder = (
tenantId: string,
poId: string,
options?: Omit<UseQueryOptions<PurchaseOrderDetail, ApiError>, 'queryKey' | 'queryFn'>
) => {
return useQuery<PurchaseOrderDetail, ApiError>({
queryKey: purchaseOrderKeys.detail(tenantId, poId),
queryFn: () => getPurchaseOrder(tenantId, poId),
enabled: !!tenantId && !!poId,
staleTime: 30 * 1000, // 30 seconds
...options,
});
};
/**
* Hook to create a new purchase order
*/
export const useCreatePurchaseOrder = (
options?: UseMutationOptions<
PurchaseOrderDetail,
ApiError,
{ tenantId: string; data: PurchaseOrderCreateData }
>
) => {
const queryClient = useQueryClient();
return useMutation<
PurchaseOrderDetail,
ApiError,
{ tenantId: string; data: PurchaseOrderCreateData }
>({
mutationFn: ({ tenantId, data }) => createPurchaseOrder(tenantId, data),
onSuccess: (data, variables) => {
// Invalidate all lists to refresh with new PO
queryClient.invalidateQueries({ queryKey: purchaseOrderKeys.lists() });
// Add to cache
queryClient.setQueryData(
purchaseOrderKeys.detail(variables.tenantId, data.id),
data
);
},
...options,
});
};
/**
* Hook to update a purchase order
*/
export const useUpdatePurchaseOrder = (
options?: UseMutationOptions<
PurchaseOrderDetail,
ApiError,
{ tenantId: string; poId: string; data: PurchaseOrderUpdateData }
>
) => {
const queryClient = useQueryClient();
return useMutation<
PurchaseOrderDetail,
ApiError,
{ tenantId: string; poId: string; data: PurchaseOrderUpdateData }
>({
mutationFn: ({ tenantId, poId, data }) => updatePurchaseOrder(tenantId, poId, data),
onSuccess: (data, variables) => {
// Invalidate and refetch related queries
queryClient.invalidateQueries({ queryKey: purchaseOrderKeys.lists() });
queryClient.invalidateQueries({
queryKey: purchaseOrderKeys.detail(variables.tenantId, variables.poId)
});
},
...options,
});
};
/**
* Hook to approve a purchase order
*/
export const useApprovePurchaseOrder = (
options?: UseMutationOptions<
PurchaseOrderDetail,
ApiError,
{ tenantId: string; poId: string; notes?: string }
>
) => {
const queryClient = useQueryClient();
return useMutation<
PurchaseOrderDetail,
ApiError,
{ tenantId: string; poId: string; notes?: string }
>({
mutationFn: ({ tenantId, poId, notes }) => approvePurchaseOrder(tenantId, poId, notes),
onSuccess: (data, variables) => {
// Invalidate pending approvals list
queryClient.invalidateQueries({
queryKey: purchaseOrderKeys.pendingApproval(variables.tenantId)
});
// Invalidate all lists
queryClient.invalidateQueries({ queryKey: purchaseOrderKeys.lists() });
// Invalidate detail
queryClient.invalidateQueries({
queryKey: purchaseOrderKeys.detail(variables.tenantId, variables.poId)
});
},
...options,
});
};
/**
* Hook to reject a purchase order
*/
export const useRejectPurchaseOrder = (
options?: UseMutationOptions<
PurchaseOrderDetail,
ApiError,
{ tenantId: string; poId: string; reason: string }
>
) => {
const queryClient = useQueryClient();
return useMutation<
PurchaseOrderDetail,
ApiError,
{ tenantId: string; poId: string; reason: string }
>({
mutationFn: ({ tenantId, poId, reason }) => rejectPurchaseOrder(tenantId, poId, reason),
onSuccess: (data, variables) => {
// Invalidate pending approvals list
queryClient.invalidateQueries({
queryKey: purchaseOrderKeys.pendingApproval(variables.tenantId)
});
// Invalidate all lists
queryClient.invalidateQueries({ queryKey: purchaseOrderKeys.lists() });
// Invalidate detail
queryClient.invalidateQueries({
queryKey: purchaseOrderKeys.detail(variables.tenantId, variables.poId)
});
},
...options,
});
};
/**
* Hook to bulk approve purchase orders
*/
export const useBulkApprovePurchaseOrders = (
options?: UseMutationOptions<
PurchaseOrderDetail[],
ApiError,
{ tenantId: string; poIds: string[]; notes?: string }
>
) => {
const queryClient = useQueryClient();
return useMutation<
PurchaseOrderDetail[],
ApiError,
{ tenantId: string; poIds: string[]; notes?: string }
>({
mutationFn: ({ tenantId, poIds, notes }) => bulkApprovePurchaseOrders(tenantId, poIds, notes),
onSuccess: (data, variables) => {
// Invalidate all PO queries
queryClient.invalidateQueries({ queryKey: purchaseOrderKeys.all });
},
...options,
});
};
/**
* Hook to delete a purchase order
*/
export const useDeletePurchaseOrder = (
options?: UseMutationOptions<
{ message: string },
ApiError,
{ tenantId: string; poId: string }
>
) => {
const queryClient = useQueryClient();
return useMutation<
{ message: string },
ApiError,
{ tenantId: string; poId: string }
>({
mutationFn: ({ tenantId, poId }) => deletePurchaseOrder(tenantId, poId),
onSuccess: (data, variables) => {
// Invalidate all PO queries
queryClient.invalidateQueries({ queryKey: purchaseOrderKeys.all });
},
...options,
});
};
/**
* Hook to create a delivery for a purchase order
*/
export const useCreateDelivery = (
options?: UseMutationOptions<
DeliveryResponse,
ApiError,
{ tenantId: string; poId: string; deliveryData: CreateDeliveryInput }
>
) => {
const queryClient = useQueryClient();
return useMutation<
DeliveryResponse,
ApiError,
{ tenantId: string; poId: string; deliveryData: CreateDeliveryInput }
>({
mutationFn: ({ tenantId, poId, deliveryData }) => createDelivery(tenantId, poId, deliveryData),
onSuccess: (data, variables) => {
// Invalidate all PO queries to refresh status
queryClient.invalidateQueries({ queryKey: purchaseOrderKeys.all });
// Invalidate detail for this specific PO
queryClient.invalidateQueries({
queryKey: purchaseOrderKeys.detail(variables.tenantId, variables.poId)
});
},
...options,
});
};

View File

@@ -0,0 +1,275 @@
// frontend/src/api/hooks/qualityTemplates.ts
/**
* React hooks for Quality Check Template API integration
*/
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { showToast } from '../../utils/toast';
import { qualityTemplateService } from '../services/qualityTemplates';
import type {
QualityCheckTemplate,
QualityCheckTemplateCreate,
QualityCheckTemplateUpdate,
QualityTemplateQueryParams,
ProcessStage,
QualityCheckExecutionRequest
} from '../types/qualityTemplates';
// Query Keys
export const qualityTemplateKeys = {
all: ['qualityTemplates'] as const,
lists: () => [...qualityTemplateKeys.all, 'list'] as const,
list: (tenantId: string, params?: QualityTemplateQueryParams) =>
[...qualityTemplateKeys.lists(), tenantId, params] as const,
details: () => [...qualityTemplateKeys.all, 'detail'] as const,
detail: (tenantId: string, templateId: string) =>
[...qualityTemplateKeys.details(), tenantId, templateId] as const,
forStage: (tenantId: string, stage: ProcessStage) =>
[...qualityTemplateKeys.all, 'stage', tenantId, stage] as const,
forRecipe: (tenantId: string, recipeId: string) =>
[...qualityTemplateKeys.all, 'recipe', tenantId, recipeId] as const,
};
/**
* Hook to fetch quality check templates
*/
export function useQualityTemplates(
tenantId: string,
params?: QualityTemplateQueryParams,
options?: { enabled?: boolean }
) {
return useQuery({
queryKey: qualityTemplateKeys.list(tenantId, params),
queryFn: () => qualityTemplateService.getTemplates(tenantId, params),
enabled: !!tenantId && (options?.enabled ?? true),
staleTime: 5 * 60 * 1000, // 5 minutes
});
}
/**
* Hook to fetch a specific quality check template
*/
export function useQualityTemplate(
tenantId: string,
templateId: string,
options?: { enabled?: boolean }
) {
return useQuery({
queryKey: qualityTemplateKeys.detail(tenantId, templateId),
queryFn: () => qualityTemplateService.getTemplate(tenantId, templateId),
enabled: !!tenantId && !!templateId && (options?.enabled ?? true),
staleTime: 10 * 60 * 1000, // 10 minutes
});
}
/**
* Hook to fetch templates for a specific process stage
*/
export function useQualityTemplatesForStage(
tenantId: string,
stage: ProcessStage,
isActive: boolean = true,
options?: { enabled?: boolean }
) {
return useQuery({
queryKey: qualityTemplateKeys.forStage(tenantId, stage),
queryFn: () => qualityTemplateService.getTemplatesForStage(tenantId, stage, isActive),
enabled: !!tenantId && !!stage && (options?.enabled ?? true),
staleTime: 5 * 60 * 1000, // 5 minutes
});
}
/**
* Hook to fetch templates organized by stages for recipe configuration
*/
export function useQualityTemplatesForRecipe(
tenantId: string,
recipeId: string,
options?: { enabled?: boolean }
) {
return useQuery({
queryKey: qualityTemplateKeys.forRecipe(tenantId, recipeId),
queryFn: () => qualityTemplateService.getTemplatesForRecipe(tenantId, recipeId),
enabled: !!tenantId && !!recipeId && (options?.enabled ?? true),
staleTime: 10 * 60 * 1000, // 10 minutes
});
}
/**
* Hook to create a quality check template
*/
export function useCreateQualityTemplate(tenantId: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (templateData: QualityCheckTemplateCreate) =>
qualityTemplateService.createTemplate(tenantId, templateData),
onSuccess: (newTemplate) => {
// Invalidate and refetch quality template lists
queryClient.invalidateQueries({ queryKey: qualityTemplateKeys.lists() });
// Add to cache
queryClient.setQueryData(
qualityTemplateKeys.detail(tenantId, newTemplate.id),
newTemplate
);
showToast.success('Plantilla de calidad creada exitosamente');
},
onError: (error: any) => {
console.error('Error creating quality template:', error);
showToast.error(error.response?.data?.detail || 'Error al crear la plantilla de calidad');
},
});
}
/**
* Hook to update a quality check template
*/
export function useUpdateQualityTemplate(tenantId: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ templateId, templateData }: {
templateId: string;
templateData: QualityCheckTemplateUpdate;
}) => qualityTemplateService.updateTemplate(tenantId, templateId, templateData),
onSuccess: (updatedTemplate, { templateId }) => {
// Update cached data
queryClient.setQueryData(
qualityTemplateKeys.detail(tenantId, templateId),
updatedTemplate
);
// Invalidate lists to refresh
queryClient.invalidateQueries({ queryKey: qualityTemplateKeys.lists() });
showToast.success('Plantilla de calidad actualizada exitosamente');
},
onError: (error: any) => {
console.error('Error updating quality template:', error);
showToast.error(error.response?.data?.detail || 'Error al actualizar la plantilla de calidad');
},
});
}
/**
* Hook to delete a quality check template
*/
export function useDeleteQualityTemplate(tenantId: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (templateId: string) =>
qualityTemplateService.deleteTemplate(tenantId, templateId),
onSuccess: (_, templateId) => {
// Remove from cache
queryClient.removeQueries({
queryKey: qualityTemplateKeys.detail(tenantId, templateId)
});
// Invalidate lists to refresh
queryClient.invalidateQueries({ queryKey: qualityTemplateKeys.lists() });
showToast.success('Plantilla de calidad eliminada exitosamente');
},
onError: (error: any) => {
console.error('Error deleting quality template:', error);
showToast.error(error.response?.data?.detail || 'Error al eliminar la plantilla de calidad');
},
});
}
/**
* Hook to duplicate a quality check template
*/
export function useDuplicateQualityTemplate(tenantId: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (templateId: string) =>
qualityTemplateService.duplicateTemplate(tenantId, templateId),
onSuccess: (duplicatedTemplate) => {
// Add to cache
queryClient.setQueryData(
qualityTemplateKeys.detail(tenantId, duplicatedTemplate.id),
duplicatedTemplate
);
// Invalidate lists to refresh
queryClient.invalidateQueries({ queryKey: qualityTemplateKeys.lists() });
showToast.success('Plantilla de calidad duplicada exitosamente');
},
onError: (error: any) => {
console.error('Error duplicating quality template:', error);
showToast.error(error.response?.data?.detail || 'Error al duplicar la plantilla de calidad');
},
});
}
/**
* Hook to execute a quality check
*/
export function useExecuteQualityCheck(tenantId: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (executionData: QualityCheckExecutionRequest) =>
qualityTemplateService.executeQualityCheck(tenantId, executionData),
onSuccess: (result, executionData) => {
// Invalidate production batch data to refresh status
queryClient.invalidateQueries({
queryKey: ['production', 'batches', tenantId]
});
// Invalidate quality check history
queryClient.invalidateQueries({
queryKey: ['qualityChecks', tenantId, executionData.batch_id]
});
const message = result.overall_pass
? 'Control de calidad completado exitosamente'
: 'Control de calidad completado con observaciones';
if (result.overall_pass) {
showToast.success(message);
} else {
showToast.error(message);
}
},
onError: (error: any) => {
console.error('Error executing quality check:', error);
showToast.error(error.response?.data?.detail || 'Error al ejecutar el control de calidad');
},
});
}
/**
* Hook to get default templates for a product category
*/
export function useDefaultQualityTemplates(
tenantId: string,
productCategory: string,
options?: { enabled?: boolean }
) {
return useQuery({
queryKey: [...qualityTemplateKeys.all, 'defaults', tenantId, productCategory],
queryFn: () => qualityTemplateService.getDefaultTemplates(tenantId, productCategory),
enabled: !!tenantId && !!productCategory && (options?.enabled ?? true),
staleTime: 15 * 60 * 1000, // 15 minutes
});
}
/**
* Hook to validate template configuration
*/
export function useValidateQualityTemplate(tenantId: string) {
return useMutation({
mutationFn: (templateData: Partial<QualityCheckTemplateCreate | QualityCheckTemplateUpdate>) =>
qualityTemplateService.validateTemplate(tenantId, templateData),
onError: (error: any) => {
console.error('Error validating quality template:', error);
},
});
}

View File

@@ -0,0 +1,313 @@
/**
* Recipes React Query hooks
* Data fetching and caching layer for recipe management
* All hooks properly handle tenant-dependent operations
*/
import {
useMutation,
useQuery,
useQueryClient,
UseQueryOptions,
UseMutationOptions,
useInfiniteQuery,
UseInfiniteQueryOptions
} from '@tanstack/react-query';
import { recipesService } from '../services/recipes';
import { ApiError } from '../client/apiClient';
import type {
RecipeResponse,
RecipeCreate,
RecipeUpdate,
RecipeDuplicateRequest,
RecipeFeasibilityResponse,
RecipeStatisticsResponse,
RecipeCategoriesResponse,
RecipeDeletionSummary,
} from '../types/recipes';
// Query Keys Factory
export const recipesKeys = {
all: ['recipes'] as const,
tenant: (tenantId: string) => [...recipesKeys.all, 'tenant', tenantId] as const,
lists: (tenantId: string) => [...recipesKeys.tenant(tenantId), 'list'] as const,
list: (tenantId: string, filters: any) => [...recipesKeys.lists(tenantId), { filters }] as const,
details: (tenantId: string) => [...recipesKeys.tenant(tenantId), 'detail'] as const,
detail: (tenantId: string, id: string) => [...recipesKeys.details(tenantId), id] as const,
statistics: (tenantId: string) => [...recipesKeys.tenant(tenantId), 'statistics'] as const,
categories: (tenantId: string) => [...recipesKeys.tenant(tenantId), 'categories'] as const,
feasibility: (tenantId: string, id: string, batchMultiplier: number) => [...recipesKeys.tenant(tenantId), 'feasibility', id, batchMultiplier] as const,
} as const;
// Recipe Queries
/**
* Fetch a single recipe by ID
*/
export const useRecipe = (
tenantId: string,
recipeId: string,
options?: Omit<UseQueryOptions<RecipeResponse, ApiError>, 'queryKey' | 'queryFn'>
) => {
return useQuery<RecipeResponse, ApiError>({
queryKey: recipesKeys.detail(tenantId, recipeId),
queryFn: () => recipesService.getRecipe(tenantId, recipeId),
enabled: !!(tenantId && recipeId),
staleTime: 5 * 60 * 1000, // 5 minutes
...options,
});
};
/**
* Search/list recipes with filters
*/
export const useRecipes = (
tenantId: string,
filters: any = {},
options?: Omit<UseQueryOptions<RecipeResponse[], ApiError>, 'queryKey' | 'queryFn'>
) => {
return useQuery<RecipeResponse[], ApiError>({
queryKey: recipesKeys.list(tenantId, filters),
queryFn: () => recipesService.searchRecipes(tenantId, filters),
staleTime: 2 * 60 * 1000, // 2 minutes
enabled: !!tenantId,
...options,
});
};
/**
* Infinite query for recipes (pagination)
*/
export const useInfiniteRecipes = (
tenantId: string,
filters: Omit<any, 'offset'> = {},
options?: Omit<UseInfiniteQueryOptions<RecipeResponse[], ApiError>, 'queryKey' | 'queryFn' | 'getNextPageParam' | 'initialPageParam'>
) => {
return useInfiniteQuery<RecipeResponse[], ApiError>({
queryKey: recipesKeys.list(tenantId, filters),
queryFn: ({ pageParam = 0 }) =>
recipesService.searchRecipes(tenantId, { ...filters, offset: pageParam }),
initialPageParam: 0,
getNextPageParam: (lastPage, allPages) => {
const limit = filters.limit || 100;
if (lastPage.length < limit) return undefined;
return allPages.length * limit;
},
enabled: !!tenantId,
staleTime: 2 * 60 * 1000, // 2 minutes
...options,
});
};
/**
* Get recipe statistics
*/
export const useRecipeStatistics = (
tenantId: string,
options?: Omit<UseQueryOptions<RecipeStatisticsResponse, ApiError>, 'queryKey' | 'queryFn'>
) => {
return useQuery<RecipeStatisticsResponse, ApiError>({
queryKey: recipesKeys.statistics(tenantId),
queryFn: () => recipesService.getRecipeStatistics(tenantId),
staleTime: 5 * 60 * 1000, // 5 minutes
enabled: !!tenantId,
...options,
});
};
/**
* Get recipe categories
*/
export const useRecipeCategories = (
tenantId: string,
options?: Omit<UseQueryOptions<RecipeCategoriesResponse, ApiError>, 'queryKey' | 'queryFn'>
) => {
return useQuery<RecipeCategoriesResponse, ApiError>({
queryKey: recipesKeys.categories(tenantId),
queryFn: () => recipesService.getRecipeCategories(tenantId),
staleTime: 10 * 60 * 1000, // 10 minutes
enabled: !!tenantId,
...options,
});
};
/**
* Check recipe feasibility
*/
export const useRecipeFeasibility = (
tenantId: string,
recipeId: string,
batchMultiplier: number = 1.0,
options?: Omit<UseQueryOptions<RecipeFeasibilityResponse, ApiError>, 'queryKey' | 'queryFn'>
) => {
return useQuery<RecipeFeasibilityResponse, ApiError>({
queryKey: recipesKeys.feasibility(tenantId, recipeId, batchMultiplier),
queryFn: () => recipesService.checkRecipeFeasibility(tenantId, recipeId, batchMultiplier),
enabled: !!(tenantId && recipeId),
staleTime: 1 * 60 * 1000, // 1 minute (fresher data for inventory checks)
...options,
});
};
// Recipe Mutations
/**
* Create a new recipe
*/
export const useCreateRecipe = (
tenantId: string,
options?: UseMutationOptions<RecipeResponse, ApiError, RecipeCreate>
) => {
const queryClient = useQueryClient();
return useMutation<RecipeResponse, ApiError, RecipeCreate>({
mutationFn: (recipeData: RecipeCreate) => recipesService.createRecipe(tenantId, recipeData),
onSuccess: (data) => {
// Add to lists cache
queryClient.invalidateQueries({ queryKey: recipesKeys.lists(tenantId) });
// Set individual recipe cache
queryClient.setQueryData(recipesKeys.detail(tenantId, data.id), data);
// Invalidate statistics
queryClient.invalidateQueries({ queryKey: recipesKeys.statistics(tenantId) });
// Invalidate categories (new category might be added)
queryClient.invalidateQueries({ queryKey: recipesKeys.categories(tenantId) });
},
...options,
});
};
/**
* Update an existing recipe
*/
export const useUpdateRecipe = (
tenantId: string,
options?: UseMutationOptions<RecipeResponse, ApiError, { id: string; data: RecipeUpdate }>
) => {
const queryClient = useQueryClient();
return useMutation<RecipeResponse, ApiError, { id: string; data: RecipeUpdate }>({
mutationFn: ({ id, data }) => recipesService.updateRecipe(tenantId, id, data),
onSuccess: (data) => {
// Update individual recipe cache
queryClient.setQueryData(recipesKeys.detail(tenantId, data.id), data);
// Invalidate lists (recipe might move in search results)
queryClient.invalidateQueries({ queryKey: recipesKeys.lists(tenantId) });
// Invalidate statistics
queryClient.invalidateQueries({ queryKey: recipesKeys.statistics(tenantId) });
// Invalidate categories
queryClient.invalidateQueries({ queryKey: recipesKeys.categories(tenantId) });
},
...options,
});
};
/**
* Delete a recipe
*/
export const useDeleteRecipe = (
tenantId: string,
options?: UseMutationOptions<{ message: string }, ApiError, string>
) => {
const queryClient = useQueryClient();
return useMutation<{ message: string }, ApiError, string>({
mutationFn: (recipeId: string) => recipesService.deleteRecipe(tenantId, recipeId),
onSuccess: (_, recipeId) => {
// Remove from individual cache
queryClient.removeQueries({ queryKey: recipesKeys.detail(tenantId, recipeId) });
// Invalidate lists
queryClient.invalidateQueries({ queryKey: recipesKeys.lists(tenantId) });
// Invalidate statistics
queryClient.invalidateQueries({ queryKey: recipesKeys.statistics(tenantId) });
// Invalidate categories
queryClient.invalidateQueries({ queryKey: recipesKeys.categories(tenantId) });
},
...options,
});
};
/**
* Archive a recipe (soft delete)
*/
export const useArchiveRecipe = (
tenantId: string,
options?: UseMutationOptions<RecipeResponse, ApiError, string>
) => {
const queryClient = useQueryClient();
return useMutation<RecipeResponse, ApiError, string>({
mutationFn: (recipeId: string) => recipesService.archiveRecipe(tenantId, recipeId),
onSuccess: (data) => {
// Update individual recipe cache
queryClient.setQueryData(recipesKeys.detail(tenantId, data.id), data);
// Invalidate lists
queryClient.invalidateQueries({ queryKey: recipesKeys.lists(tenantId) });
// Invalidate statistics
queryClient.invalidateQueries({ queryKey: recipesKeys.statistics(tenantId) });
},
...options,
});
};
/**
* Get deletion summary for a recipe
*/
export const useRecipeDeletionSummary = (
tenantId: string,
recipeId: string,
options?: Omit<UseQueryOptions<RecipeDeletionSummary, ApiError>, 'queryKey' | 'queryFn'>
) => {
return useQuery<RecipeDeletionSummary, ApiError>({
queryKey: [...recipesKeys.detail(tenantId, recipeId), 'deletion-summary'],
queryFn: () => recipesService.getRecipeDeletionSummary(tenantId, recipeId),
enabled: !!(tenantId && recipeId),
staleTime: 0, // Always fetch fresh data
...options,
});
};
/**
* Duplicate a recipe
*/
export const useDuplicateRecipe = (
tenantId: string,
options?: UseMutationOptions<RecipeResponse, ApiError, { id: string; data: RecipeDuplicateRequest }>
) => {
const queryClient = useQueryClient();
return useMutation<RecipeResponse, ApiError, { id: string; data: RecipeDuplicateRequest }>({
mutationFn: ({ id, data }) => recipesService.duplicateRecipe(tenantId, id, data),
onSuccess: (data) => {
// Add to lists cache
queryClient.invalidateQueries({ queryKey: recipesKeys.lists(tenantId) });
// Set individual recipe cache
queryClient.setQueryData(recipesKeys.detail(tenantId, data.id), data);
// Invalidate statistics
queryClient.invalidateQueries({ queryKey: recipesKeys.statistics(tenantId) });
},
...options,
});
};
/**
* Activate a recipe
*/
export const useActivateRecipe = (
tenantId: string,
options?: UseMutationOptions<RecipeResponse, ApiError, string>
) => {
const queryClient = useQueryClient();
return useMutation<RecipeResponse, ApiError, string>({
mutationFn: (recipeId: string) => recipesService.activateRecipe(tenantId, recipeId),
onSuccess: (data) => {
// Update individual recipe cache
queryClient.setQueryData(recipesKeys.detail(tenantId, data.id), data);
// Invalidate lists
queryClient.invalidateQueries({ queryKey: recipesKeys.lists(tenantId) });
// Invalidate statistics
queryClient.invalidateQueries({ queryKey: recipesKeys.statistics(tenantId) });
},
...options,
});
};

View File

@@ -0,0 +1,215 @@
/**
* Sales React Query hooks
*/
import { useMutation, useQuery, useQueryClient, UseQueryOptions, UseMutationOptions } from '@tanstack/react-query';
import { salesService } from '../services/sales';
import {
SalesDataCreate,
SalesDataUpdate,
SalesDataResponse,
SalesDataQuery,
SalesAnalytics,
} from '../types/sales';
import { ApiError } from '../client';
// Query Keys
export const salesKeys = {
all: ['sales'] as const,
lists: () => [...salesKeys.all, 'list'] as const,
list: (tenantId: string, filters?: SalesDataQuery) => [...salesKeys.lists(), tenantId, filters] as const,
details: () => [...salesKeys.all, 'detail'] as const,
detail: (tenantId: string, recordId: string) => [...salesKeys.details(), tenantId, recordId] as const,
analytics: (tenantId: string, startDate?: string, endDate?: string) =>
[...salesKeys.all, 'analytics', tenantId, { startDate, endDate }] as const,
productSales: (tenantId: string, productId: string, startDate?: string, endDate?: string) =>
[...salesKeys.all, 'product-sales', tenantId, productId, { startDate, endDate }] as const,
categories: (tenantId: string) => [...salesKeys.all, 'categories', tenantId] as const,
} as const;
// Queries
export const useSalesRecords = (
tenantId: string,
query?: SalesDataQuery,
options?: Omit<UseQueryOptions<SalesDataResponse[], ApiError>, 'queryKey' | 'queryFn'>
) => {
return useQuery<SalesDataResponse[], ApiError>({
queryKey: salesKeys.list(tenantId, query),
queryFn: () => salesService.getSalesRecords(tenantId, query),
enabled: !!tenantId,
staleTime: 30 * 1000, // 30 seconds
...options,
});
};
export const useSalesRecord = (
tenantId: string,
recordId: string,
options?: Omit<UseQueryOptions<SalesDataResponse, ApiError>, 'queryKey' | 'queryFn'>
) => {
return useQuery<SalesDataResponse, ApiError>({
queryKey: salesKeys.detail(tenantId, recordId),
queryFn: () => salesService.getSalesRecord(tenantId, recordId),
enabled: !!tenantId && !!recordId,
staleTime: 2 * 60 * 1000, // 2 minutes
...options,
});
};
export const useSalesAnalytics = (
tenantId: string,
startDate?: string,
endDate?: string,
options?: Omit<UseQueryOptions<SalesAnalytics, ApiError>, 'queryKey' | 'queryFn'>
) => {
return useQuery<SalesAnalytics, ApiError>({
queryKey: salesKeys.analytics(tenantId, startDate, endDate),
queryFn: () => salesService.getSalesAnalytics(tenantId, startDate, endDate),
enabled: !!tenantId,
staleTime: 5 * 60 * 1000, // 5 minutes
...options,
});
};
export const useProductSales = (
tenantId: string,
inventoryProductId: string,
startDate?: string,
endDate?: string,
options?: Omit<UseQueryOptions<SalesDataResponse[], ApiError>, 'queryKey' | 'queryFn'>
) => {
return useQuery<SalesDataResponse[], ApiError>({
queryKey: salesKeys.productSales(tenantId, inventoryProductId, startDate, endDate),
queryFn: () => salesService.getProductSales(tenantId, inventoryProductId, startDate, endDate),
enabled: !!tenantId && !!inventoryProductId,
staleTime: 2 * 60 * 1000, // 2 minutes
...options,
});
};
export const useProductCategories = (
tenantId: string,
options?: Omit<UseQueryOptions<string[], ApiError>, 'queryKey' | 'queryFn'>
) => {
return useQuery<string[], ApiError>({
queryKey: salesKeys.categories(tenantId),
queryFn: () => salesService.getProductCategories(tenantId),
enabled: !!tenantId,
staleTime: 10 * 60 * 1000, // 10 minutes
...options,
});
};
// Mutations
export const useCreateSalesRecord = (
options?: UseMutationOptions<SalesDataResponse, ApiError, { tenantId: string; salesData: SalesDataCreate }>
) => {
const queryClient = useQueryClient();
return useMutation<SalesDataResponse, ApiError, { tenantId: string; salesData: SalesDataCreate }>({
mutationFn: ({ tenantId, salesData }) => salesService.createSalesRecord(tenantId, salesData),
onSuccess: (data, { tenantId }) => {
// Invalidate sales lists to refresh data
queryClient.invalidateQueries({ queryKey: salesKeys.lists() });
queryClient.invalidateQueries({ queryKey: salesKeys.analytics(tenantId) });
// Set the new record in cache
queryClient.setQueryData(salesKeys.detail(tenantId, data.id), data);
},
...options,
});
};
export const useUpdateSalesRecord = (
options?: UseMutationOptions<
SalesDataResponse,
ApiError,
{ tenantId: string; recordId: string; updateData: SalesDataUpdate }
>
) => {
const queryClient = useQueryClient();
return useMutation<
SalesDataResponse,
ApiError,
{ tenantId: string; recordId: string; updateData: SalesDataUpdate }
>({
mutationFn: ({ tenantId, recordId, updateData }) =>
salesService.updateSalesRecord(tenantId, recordId, updateData),
onSuccess: (data, { tenantId, recordId }) => {
// Update the record cache
queryClient.setQueryData(salesKeys.detail(tenantId, recordId), data);
// Invalidate related queries
queryClient.invalidateQueries({ queryKey: salesKeys.lists() });
queryClient.invalidateQueries({ queryKey: salesKeys.analytics(tenantId) });
},
...options,
});
};
export const useDeleteSalesRecord = (
options?: UseMutationOptions<{ message: string }, ApiError, { tenantId: string; recordId: string }>
) => {
const queryClient = useQueryClient();
return useMutation<{ message: string }, ApiError, { tenantId: string; recordId: string }>({
mutationFn: ({ tenantId, recordId }) => salesService.deleteSalesRecord(tenantId, recordId),
onSuccess: (data, { tenantId, recordId }) => {
// Remove from cache
queryClient.removeQueries({ queryKey: salesKeys.detail(tenantId, recordId) });
// Invalidate related queries
queryClient.invalidateQueries({ queryKey: salesKeys.lists() });
queryClient.invalidateQueries({ queryKey: salesKeys.analytics(tenantId) });
},
...options,
});
};
export const useValidateSalesRecord = (
options?: UseMutationOptions<
SalesDataResponse,
ApiError,
{ tenantId: string; recordId: string; validationNotes?: string }
>
) => {
const queryClient = useQueryClient();
return useMutation<
SalesDataResponse,
ApiError,
{ tenantId: string; recordId: string; validationNotes?: string }
>({
mutationFn: ({ tenantId, recordId, validationNotes }) =>
salesService.validateSalesRecord(tenantId, recordId, validationNotes),
onSuccess: (data, { tenantId, recordId }) => {
// Update the record cache
queryClient.setQueryData(salesKeys.detail(tenantId, recordId), data);
// Invalidate sales lists to reflect validation status
queryClient.invalidateQueries({ queryKey: salesKeys.lists() });
},
...options,
});
};
// Import/Export operations
export const useValidateImportFile = (
options?: UseMutationOptions<any, ApiError, { tenantId: string; file: File }>
) => {
return useMutation<any, ApiError, { tenantId: string; file: File }>({
mutationFn: ({ tenantId, file }) => salesService.validateImportFile(tenantId, file),
...options,
});
};
export const useImportSalesData = (
options?: UseMutationOptions<any, ApiError, { tenantId: string; file: File }>
) => {
const queryClient = useQueryClient();
return useMutation<any, ApiError, { tenantId: string; file: File }>({
mutationFn: ({ tenantId, file }) => salesService.importSalesData(tenantId, file),
onSuccess: (data, { tenantId }) => {
// Invalidate sales lists to include imported data
queryClient.invalidateQueries({ queryKey: salesKeys.lists() });
queryClient.invalidateQueries({ queryKey: salesKeys.analytics(tenantId) });
},
...options,
});
};

View File

@@ -0,0 +1,135 @@
// frontend/src/api/hooks/settings.ts
/**
* React Query hooks for Tenant Settings
* Provides data fetching, caching, and mutation hooks
*/
import { useQuery, useMutation, useQueryClient, UseQueryOptions } from '@tanstack/react-query';
import { settingsApi } from '../services/settings';
import { showToast } from '../../utils/toast';
import type {
TenantSettings,
TenantSettingsUpdate,
SettingsCategory,
CategoryResetResponse,
} from '../types/settings';
// Query keys
export const settingsKeys = {
all: ['settings'] as const,
tenant: (tenantId: string) => ['settings', tenantId] as const,
category: (tenantId: string, category: SettingsCategory) =>
['settings', tenantId, category] as const,
};
/**
* Hook to fetch all settings for a tenant
*/
export const useSettings = (
tenantId: string,
options?: Omit<UseQueryOptions<TenantSettings, Error>, 'queryKey' | 'queryFn'>
) => {
return useQuery<TenantSettings, Error>({
queryKey: settingsKeys.tenant(tenantId),
queryFn: () => settingsApi.getSettings(tenantId),
staleTime: 5 * 60 * 1000, // 5 minutes
...options,
});
};
/**
* Hook to fetch settings for a specific category
*/
export const useCategorySettings = (
tenantId: string,
category: SettingsCategory,
options?: Omit<UseQueryOptions<Record<string, any>, Error>, 'queryKey' | 'queryFn'>
) => {
return useQuery<Record<string, any>, Error>({
queryKey: settingsKeys.category(tenantId, category),
queryFn: () => settingsApi.getCategorySettings(tenantId, category),
staleTime: 5 * 60 * 1000, // 5 minutes
...options,
});
};
/**
* Hook to update tenant settings
*/
export const useUpdateSettings = () => {
const queryClient = useQueryClient();
return useMutation<
TenantSettings,
Error,
{ tenantId: string; updates: TenantSettingsUpdate }
>({
mutationFn: ({ tenantId, updates }) => settingsApi.updateSettings(tenantId, updates),
onSuccess: (data, variables) => {
// Invalidate all settings queries for this tenant
queryClient.invalidateQueries({ queryKey: settingsKeys.tenant(variables.tenantId) });
showToast.success('Ajustes guardados correctamente');
},
onError: (error) => {
console.error('Failed to update settings:', error);
showToast.error('Error al guardar los ajustes');
},
});
};
/**
* Hook to update a specific category
*/
export const useUpdateCategorySettings = () => {
const queryClient = useQueryClient();
return useMutation<
TenantSettings,
Error,
{ tenantId: string; category: SettingsCategory; settings: Record<string, any> }
>({
mutationFn: ({ tenantId, category, settings }) =>
settingsApi.updateCategorySettings(tenantId, category, settings),
onSuccess: (data, variables) => {
// Invalidate all settings queries for this tenant
queryClient.invalidateQueries({ queryKey: settingsKeys.tenant(variables.tenantId) });
// Also invalidate the specific category query
queryClient.invalidateQueries({
queryKey: settingsKeys.category(variables.tenantId, variables.category),
});
showToast.success('Ajustes de categoría guardados correctamente');
},
onError: (error) => {
console.error('Failed to update category settings:', error);
showToast.error('Error al guardar los ajustes de categoría');
},
});
};
/**
* Hook to reset a category to defaults
*/
export const useResetCategory = () => {
const queryClient = useQueryClient();
return useMutation<
CategoryResetResponse,
Error,
{ tenantId: string; category: SettingsCategory }
>({
mutationFn: ({ tenantId, category }) => settingsApi.resetCategory(tenantId, category),
onSuccess: (data, variables) => {
// Invalidate all settings queries for this tenant
queryClient.invalidateQueries({ queryKey: settingsKeys.tenant(variables.tenantId) });
// Also invalidate the specific category query
queryClient.invalidateQueries({
queryKey: settingsKeys.category(variables.tenantId, variables.category),
});
showToast.success(`Categoría '${variables.category}' restablecida a valores predeterminados`);
},
onError: (error) => {
console.error('Failed to reset category:', error);
showToast.error('Error al restablecer la categoría');
},
});
};

View File

@@ -0,0 +1,194 @@
/**
* Subscription hook for checking plan features and limits
*/
import { useState, useEffect, useCallback, useMemo } from 'react';
import { useQuery } from '@tanstack/react-query';
import { subscriptionService } from '../services/subscription';
import {
SUBSCRIPTION_TIERS,
SubscriptionTier
} from '../types/subscription';
import { useCurrentTenant } from '../../stores';
import { useAuthUser, useJWTSubscription } from '../../stores/auth.store';
import { useSubscriptionEvents } from '../../contexts/SubscriptionEventsContext';
export interface SubscriptionFeature {
hasFeature: boolean;
featureLevel?: string;
reason?: string;
}
export interface SubscriptionLimits {
canAddUser: boolean;
canAddLocation: boolean;
canAddProduct: boolean;
usageData?: any;
}
export interface SubscriptionInfo {
plan: string;
status: 'active' | 'inactive' | 'past_due' | 'cancelled' | 'trialing';
features: Record<string, any>;
loading: boolean;
error?: string;
}
export const useSubscription = () => {
const currentTenant = useCurrentTenant();
const user = useAuthUser();
const tenantId = currentTenant?.id || user?.tenant_id;
const { subscriptionVersion } = useSubscriptionEvents();
// Initialize with tenant's subscription_plan if available, otherwise default to starter
const initialPlan = currentTenant?.subscription_plan || currentTenant?.subscription_tier || 'starter';
// Use React Query to fetch subscription data (automatically deduplicates & caches)
const { data: usageSummary, isLoading, error, refetch } = useQuery({
queryKey: ['subscription-usage', tenantId, subscriptionVersion],
queryFn: () => subscriptionService.getUsageSummary(tenantId!),
enabled: !!tenantId,
staleTime: 30 * 1000, // Cache for 30 seconds (matches backend cache)
gcTime: 5 * 60 * 1000, // Keep in cache for 5 minutes
retry: 1,
});
// Get JWT subscription data for instant rendering
const jwtSubscription = useJWTSubscription();
// Derive subscription info from query data or tenant fallback
// IMPORTANT: Memoize to prevent infinite re-renders in dependent hooks
const subscriptionInfo: SubscriptionInfo = useMemo(() => {
// If we have fresh API data (from loadSubscriptionData), use it
// This handles the case where token refresh failed but API call succeeded
const apiPlan = usageSummary?.plan;
const jwtPlan = jwtSubscription?.tier;
// Prefer API data if available and more recent
// Ensure status is compatible with SubscriptionInfo interface
const rawStatus = usageSummary?.status || jwtSubscription?.status || 'active';
const status = (() => {
switch (rawStatus) {
case 'active':
case 'inactive':
case 'past_due':
case 'cancelled':
case 'trialing':
return rawStatus;
default:
return 'active';
}
})();
return {
plan: apiPlan || jwtPlan || initialPlan,
status: status,
features: usageSummary?.usage || {},
loading: isLoading && !apiPlan && !jwtPlan,
error: error ? 'Failed to load subscription data' : undefined,
fromJWT: !apiPlan && !!jwtPlan,
};
}, [jwtSubscription, usageSummary?.plan, usageSummary?.status, usageSummary?.usage, initialPlan, isLoading, error]);
// Check if user has a specific feature
const hasFeature = useCallback(async (featureName: string): Promise<SubscriptionFeature> => {
if (!tenantId) {
return { hasFeature: false, reason: 'No tenant ID available' };
}
try {
const result = await subscriptionService.hasFeature(tenantId, featureName);
return {
hasFeature: result.has_feature,
featureLevel: result.feature_value,
reason: result.reason
};
} catch (error) {
console.error('Error checking feature:', error);
return { hasFeature: false, reason: 'Error checking feature access' };
}
}, [tenantId]);
// Check analytics access level
const getAnalyticsAccess = useCallback((): { hasAccess: boolean; level: string; reason?: string } => {
const plan = subscriptionInfo.plan;
// Convert plan string to typed SubscriptionTier
let tierKey: SubscriptionTier | undefined;
if (plan === SUBSCRIPTION_TIERS.STARTER) tierKey = SUBSCRIPTION_TIERS.STARTER;
else if (plan === SUBSCRIPTION_TIERS.PROFESSIONAL) tierKey = SUBSCRIPTION_TIERS.PROFESSIONAL;
else if (plan === SUBSCRIPTION_TIERS.ENTERPRISE) tierKey = SUBSCRIPTION_TIERS.ENTERPRISE;
if (tierKey) {
const analyticsLevel = subscriptionService.getAnalyticsLevelForTier(tierKey);
return { hasAccess: true, level: analyticsLevel };
}
// Default fallback when plan is not recognized
return { hasAccess: false, level: 'none', reason: 'Unknown plan' };
}, [subscriptionInfo.plan]);
// Check if user can access specific analytics features
const canAccessAnalytics = useCallback((requiredLevel: 'basic' | 'advanced' | 'predictive' = 'basic'): boolean => {
const { hasAccess, level } = getAnalyticsAccess();
if (!hasAccess) return false;
return subscriptionService.doesAnalyticsLevelMeetMinimum(level as any, requiredLevel);
}, [getAnalyticsAccess]);
// Check if user can access forecasting features
const canAccessForecasting = useCallback((): boolean => {
return canAccessAnalytics('advanced'); // Forecasting requires advanced or higher
}, [canAccessAnalytics]);
// Check if user can access AI insights
const canAccessAIInsights = useCallback((): boolean => {
return canAccessAnalytics('predictive'); // AI Insights requires enterprise plan
}, [canAccessAnalytics]);
// Check usage limits
const checkLimits = useCallback(async (): Promise<SubscriptionLimits> => {
if (!tenantId) {
return {
canAddUser: false,
canAddLocation: false,
canAddProduct: false
};
}
try {
const [userCheck, locationCheck, productCheck] = await Promise.all([
subscriptionService.canAddUser(tenantId),
subscriptionService.canAddLocation(tenantId),
subscriptionService.canAddProduct(tenantId)
]);
return {
canAddUser: userCheck.can_add,
canAddLocation: locationCheck.can_add,
canAddProduct: productCheck.can_add,
};
} catch (error) {
console.error('Error checking limits:', error);
return {
canAddUser: false,
canAddLocation: false,
canAddProduct: false
};
}
}, [tenantId]);
return {
subscriptionInfo,
hasFeature,
getAnalyticsAccess,
canAccessAnalytics,
canAccessForecasting,
canAccessAIInsights,
checkLimits,
refreshSubscription: refetch,
};
};
export default useSubscription;

View File

@@ -0,0 +1,682 @@
/**
* Suppliers React Query hooks
* Provides data fetching, caching, and state management for supplier operations
*/
import { useMutation, useQuery, useQueryClient, UseQueryOptions, UseMutationOptions } from '@tanstack/react-query';
import { suppliersService } from '../services/suppliers';
import { ApiError } from '../client/apiClient';
import type {
SupplierCreate,
SupplierUpdate,
SupplierResponse,
SupplierSummary,
SupplierApproval,
SupplierSearchParams,
SupplierStatistics,
SupplierDeletionSummary,
DeliveryCreate,
DeliveryUpdate,
DeliveryResponse,
DeliveryReceiptConfirmation,
DeliverySearchParams,
PerformanceMetric,
SupplierPriceListCreate,
SupplierPriceListUpdate,
SupplierPriceListResponse,
} from '../types/suppliers';
// Query Keys Factory
export const suppliersKeys = {
all: ['suppliers'] as const,
suppliers: {
all: () => [...suppliersKeys.all, 'suppliers'] as const,
lists: () => [...suppliersKeys.suppliers.all(), 'list'] as const,
list: (tenantId: string, params?: SupplierSearchParams) =>
[...suppliersKeys.suppliers.lists(), tenantId, params] as const,
details: () => [...suppliersKeys.suppliers.all(), 'detail'] as const,
detail: (tenantId: string, supplierId: string) =>
[...suppliersKeys.suppliers.details(), tenantId, supplierId] as const,
statistics: (tenantId: string) =>
[...suppliersKeys.suppliers.all(), 'statistics', tenantId] as const,
top: (tenantId: string) =>
[...suppliersKeys.suppliers.all(), 'top', tenantId] as const,
byType: (tenantId: string, supplierType: string) =>
[...suppliersKeys.suppliers.all(), 'by-type', tenantId, supplierType] as const,
},
deliveries: {
all: () => [...suppliersKeys.all, 'deliveries'] as const,
lists: () => [...suppliersKeys.deliveries.all(), 'list'] as const,
list: (params?: DeliverySearchParams) =>
[...suppliersKeys.deliveries.lists(), params] as const,
details: () => [...suppliersKeys.deliveries.all(), 'detail'] as const,
detail: (deliveryId: string) =>
[...suppliersKeys.deliveries.details(), deliveryId] as const,
},
performance: {
all: () => [...suppliersKeys.all, 'performance'] as const,
metrics: (tenantId: string, supplierId: string) =>
[...suppliersKeys.performance.all(), 'metrics', tenantId, supplierId] as const,
alerts: (tenantId: string, supplierId?: string) =>
[...suppliersKeys.performance.all(), 'alerts', tenantId, supplierId] as const,
},
} as const;
// Supplier Queries
export const useSuppliers = (
tenantId: string,
queryParams?: SupplierSearchParams,
options?: Omit<UseQueryOptions<SupplierSummary[], ApiError>, 'queryKey' | 'queryFn'>
) => {
return useQuery<SupplierSummary[], ApiError>({
queryKey: suppliersKeys.suppliers.list(tenantId, queryParams),
queryFn: () => suppliersService.getSuppliers(tenantId, queryParams),
enabled: !!tenantId,
staleTime: 2 * 60 * 1000, // 2 minutes
...options,
});
};
export const useSupplier = (
tenantId: string,
supplierId: string,
options?: Omit<UseQueryOptions<SupplierResponse, ApiError>, 'queryKey' | 'queryFn'>
) => {
return useQuery<SupplierResponse, ApiError>({
queryKey: suppliersKeys.suppliers.detail(tenantId, supplierId),
queryFn: () => suppliersService.getSupplier(tenantId, supplierId),
enabled: !!tenantId && !!supplierId,
staleTime: 5 * 60 * 1000, // 5 minutes
...options,
});
};
export const useSupplierStatistics = (
tenantId: string,
options?: Omit<UseQueryOptions<SupplierStatistics, ApiError>, 'queryKey' | 'queryFn'>
) => {
return useQuery<SupplierStatistics, ApiError>({
queryKey: suppliersKeys.suppliers.statistics(tenantId),
queryFn: () => suppliersService.getSupplierStatistics(tenantId),
enabled: !!tenantId,
staleTime: 5 * 60 * 1000, // 5 minutes
...options,
});
};
export const useActiveSuppliers = (
tenantId: string,
queryParams?: Omit<SupplierSearchParams, 'status'>,
options?: Omit<UseQueryOptions<SupplierSummary[], ApiError>, 'queryKey' | 'queryFn'>
) => {
return useQuery<SupplierSummary[], ApiError>({
queryKey: suppliersKeys.suppliers.list(tenantId, { ...queryParams }),
queryFn: () => suppliersService.getActiveSuppliers(tenantId, queryParams),
enabled: !!tenantId,
staleTime: 2 * 60 * 1000, // 2 minutes
...options,
});
};
export const useTopSuppliers = (
tenantId: string,
options?: Omit<UseQueryOptions<SupplierResponse[], ApiError>, 'queryKey' | 'queryFn'>
) => {
return useQuery<SupplierResponse[], ApiError>({
queryKey: suppliersKeys.suppliers.top(tenantId),
queryFn: () => suppliersService.getTopSuppliers(tenantId),
enabled: !!tenantId,
staleTime: 10 * 60 * 1000, // 10 minutes
...options,
});
};
export const usePendingApprovalSuppliers = (
tenantId: string,
options?: Omit<UseQueryOptions<SupplierSummary[], ApiError>, 'queryKey' | 'queryFn'>
) => {
return useQuery<SupplierSummary[], ApiError>({
queryKey: suppliersKeys.suppliers.list(tenantId, {}),
queryFn: () => suppliersService.getPendingApprovalSuppliers(tenantId),
enabled: !!tenantId,
staleTime: 1 * 60 * 1000, // 1 minute
...options,
});
};
export const useSuppliersByType = (
tenantId: string,
supplierType: string,
queryParams?: Omit<SupplierSearchParams, 'supplier_type'>,
options?: Omit<UseQueryOptions<SupplierSummary[], ApiError>, 'queryKey' | 'queryFn'>
) => {
return useQuery<SupplierSummary[], ApiError>({
queryKey: suppliersKeys.suppliers.byType(tenantId, supplierType),
queryFn: () => suppliersService.getSuppliersByType(tenantId, supplierType, queryParams),
enabled: !!tenantId && !!supplierType,
staleTime: 2 * 60 * 1000, // 2 minutes
...options,
});
};
// Delivery Queries
export const useDeliveries = (
tenantId: string,
queryParams?: DeliverySearchParams,
options?: Omit<UseQueryOptions<DeliveryResponse[], ApiError>, 'queryKey' | 'queryFn'>
) => {
return useQuery<DeliveryResponse[], ApiError>({
queryKey: suppliersKeys.deliveries.list(queryParams),
queryFn: () => suppliersService.getDeliveries(tenantId, queryParams as any),
enabled: !!tenantId,
staleTime: 1 * 60 * 1000, // 1 minute
...options,
});
};
export const useDelivery = (
tenantId: string,
deliveryId: string,
options?: Omit<UseQueryOptions<DeliveryResponse, ApiError>, 'queryKey' | 'queryFn'>
) => {
return useQuery<DeliveryResponse, ApiError>({
queryKey: suppliersKeys.deliveries.detail(deliveryId),
queryFn: () => suppliersService.getDelivery(tenantId, deliveryId),
enabled: !!tenantId && !!deliveryId,
staleTime: 30 * 1000, // 30 seconds
...options,
});
};
// Supplier Price List Queries
export const useSupplierPriceLists = (
tenantId: string,
supplierId: string,
isActive: boolean = true,
options?: Omit<UseQueryOptions<SupplierPriceListResponse[], ApiError>, 'queryKey' | 'queryFn'>
) => {
return useQuery<SupplierPriceListResponse[], ApiError>({
queryKey: [...suppliersKeys.suppliers.detail(tenantId, supplierId), 'price-lists', isActive],
queryFn: () => suppliersService.getSupplierPriceLists(tenantId, supplierId, isActive),
enabled: !!tenantId && !!supplierId,
staleTime: 5 * 60 * 1000, // 5 minutes
...options,
});
};
export const useSupplierPriceList = (
tenantId: string,
supplierId: string,
priceListId: string,
options?: Omit<UseQueryOptions<SupplierPriceListResponse, ApiError>, 'queryKey' | 'queryFn'>
) => {
return useQuery<SupplierPriceListResponse, ApiError>({
queryKey: [...suppliersKeys.suppliers.detail(tenantId, supplierId), 'price-list', priceListId],
queryFn: () => suppliersService.getSupplierPriceList(tenantId, supplierId, priceListId),
enabled: !!tenantId && !!supplierId && !!priceListId,
staleTime: 5 * 60 * 1000, // 5 minutes
...options,
});
};
// Performance Queries
export const useSupplierPerformanceMetrics = (
tenantId: string,
supplierId: string,
options?: Omit<UseQueryOptions<PerformanceMetric[], ApiError>, 'queryKey' | 'queryFn'>
) => {
return useQuery<PerformanceMetric[], ApiError>({
queryKey: suppliersKeys.performance.metrics(tenantId, supplierId),
queryFn: () => suppliersService.getSupplierPerformanceMetrics(tenantId, supplierId),
enabled: !!tenantId && !!supplierId,
staleTime: 10 * 60 * 1000, // 10 minutes
...options,
});
};
export const usePerformanceAlerts = (
tenantId: string,
supplierId?: string,
options?: Omit<UseQueryOptions<any[], ApiError>, 'queryKey' | 'queryFn'>
) => {
return useQuery<any[], ApiError>({
queryKey: suppliersKeys.performance.alerts(tenantId, supplierId),
queryFn: () => suppliersService.getPerformanceAlerts(tenantId, supplierId),
enabled: !!tenantId,
staleTime: 2 * 60 * 1000, // 2 minutes
...options,
});
};
// Supplier Mutations
export const useCreateSupplier = (
options?: UseMutationOptions<
SupplierResponse,
ApiError,
{ tenantId: string; supplierData: SupplierCreate }
>
) => {
const queryClient = useQueryClient();
return useMutation<
SupplierResponse,
ApiError,
{ tenantId: string; supplierData: SupplierCreate }
>({
mutationFn: ({ tenantId, supplierData }) =>
suppliersService.createSupplier(tenantId, supplierData),
onSuccess: (data, { tenantId }) => {
// Add to cache
queryClient.setQueryData(
suppliersKeys.suppliers.detail(tenantId, data.id),
data
);
// Invalidate lists and statistics
queryClient.invalidateQueries({
queryKey: suppliersKeys.suppliers.lists()
});
queryClient.invalidateQueries({
queryKey: suppliersKeys.suppliers.statistics(tenantId)
});
},
...options,
});
};
export const useUpdateSupplier = (
options?: UseMutationOptions<
SupplierResponse,
ApiError,
{ tenantId: string; supplierId: string; updateData: SupplierUpdate }
>
) => {
const queryClient = useQueryClient();
return useMutation<
SupplierResponse,
ApiError,
{ tenantId: string; supplierId: string; updateData: SupplierUpdate }
>({
mutationFn: ({ tenantId, supplierId, updateData }) =>
suppliersService.updateSupplier(tenantId, supplierId, updateData),
onSuccess: (data, { tenantId, supplierId }) => {
// Update cache
queryClient.setQueryData(
suppliersKeys.suppliers.detail(tenantId, supplierId),
data
);
// Invalidate lists
queryClient.invalidateQueries({
queryKey: suppliersKeys.suppliers.lists()
});
},
...options,
});
};
export const useApproveSupplier = (
options?: UseMutationOptions<
SupplierResponse,
ApiError,
{ tenantId: string; supplierId: string; approvalData: SupplierApproval }
>
) => {
const queryClient = useQueryClient();
return useMutation<
SupplierResponse,
ApiError,
{ tenantId: string; supplierId: string; approvalData: SupplierApproval }
>({
mutationFn: ({ tenantId, supplierId, approvalData }) =>
suppliersService.approveSupplier(tenantId, supplierId, approvalData),
onSuccess: (data, { tenantId, supplierId }) => {
// Update cache with new supplier status
queryClient.setQueryData(
suppliersKeys.suppliers.detail(tenantId, supplierId),
data
);
// Invalidate lists and statistics as approval changes counts
queryClient.invalidateQueries({
queryKey: suppliersKeys.suppliers.lists()
});
queryClient.invalidateQueries({
queryKey: suppliersKeys.suppliers.statistics(tenantId)
});
},
...options,
});
};
export const useDeleteSupplier = (
options?: UseMutationOptions<
{ message: string },
ApiError,
{ tenantId: string; supplierId: string }
>
) => {
const queryClient = useQueryClient();
return useMutation<
{ message: string },
ApiError,
{ tenantId: string; supplierId: string }
>({
mutationFn: ({ tenantId, supplierId }) =>
suppliersService.deleteSupplier(tenantId, supplierId),
onSuccess: (_, { tenantId, supplierId }) => {
// Remove from cache
queryClient.removeQueries({
queryKey: suppliersKeys.suppliers.detail(tenantId, supplierId)
});
// Invalidate lists and statistics
queryClient.invalidateQueries({
queryKey: suppliersKeys.suppliers.lists()
});
queryClient.invalidateQueries({
queryKey: suppliersKeys.suppliers.statistics(tenantId)
});
},
...options,
});
};
export const useHardDeleteSupplier = (
options?: UseMutationOptions<
SupplierDeletionSummary,
ApiError,
{ tenantId: string; supplierId: string }
>
) => {
const queryClient = useQueryClient();
return useMutation<
SupplierDeletionSummary,
ApiError,
{ tenantId: string; supplierId: string }
>({
mutationFn: ({ tenantId, supplierId }) =>
suppliersService.hardDeleteSupplier(tenantId, supplierId),
onSuccess: (_, { tenantId, supplierId }) => {
// Remove from cache
queryClient.removeQueries({
queryKey: suppliersKeys.suppliers.detail(tenantId, supplierId)
});
// Invalidate lists and statistics
queryClient.invalidateQueries({
queryKey: suppliersKeys.suppliers.lists()
});
queryClient.invalidateQueries({
queryKey: suppliersKeys.suppliers.statistics(tenantId)
});
},
...options,
});
};
// Delivery Mutations
export const useCreateDelivery = (
options?: UseMutationOptions<DeliveryResponse, ApiError, DeliveryCreate>
) => {
const queryClient = useQueryClient();
return useMutation<DeliveryResponse, ApiError, DeliveryCreate>({
mutationFn: (deliveryData) => suppliersService.createDelivery(deliveryData),
onSuccess: (data) => {
// Add to cache
queryClient.setQueryData(
suppliersKeys.deliveries.detail(data.id),
data
);
// Invalidate lists
queryClient.invalidateQueries({
queryKey: suppliersKeys.deliveries.lists()
});
},
...options,
});
};
export const useUpdateDelivery = (
options?: UseMutationOptions<
DeliveryResponse,
ApiError,
{ deliveryId: string; updateData: DeliveryUpdate }
>
) => {
const queryClient = useQueryClient();
return useMutation<
DeliveryResponse,
ApiError,
{ deliveryId: string; updateData: DeliveryUpdate }
>({
mutationFn: ({ deliveryId, updateData }) =>
suppliersService.updateDelivery(deliveryId, updateData),
onSuccess: (data, { deliveryId }) => {
// Update cache
queryClient.setQueryData(
suppliersKeys.deliveries.detail(deliveryId),
data
);
// Invalidate lists
queryClient.invalidateQueries({
queryKey: suppliersKeys.deliveries.lists()
});
},
...options,
});
};
export const useConfirmDeliveryReceipt = (
options?: UseMutationOptions<
DeliveryResponse,
ApiError,
{ deliveryId: string; confirmation: DeliveryReceiptConfirmation }
>
) => {
const queryClient = useQueryClient();
return useMutation<
DeliveryResponse,
ApiError,
{ deliveryId: string; confirmation: DeliveryReceiptConfirmation }
>({
mutationFn: ({ deliveryId, confirmation }) =>
suppliersService.confirmDeliveryReceipt(deliveryId, confirmation),
onSuccess: (data, { deliveryId }) => {
// Update cache
queryClient.setQueryData(
suppliersKeys.deliveries.detail(deliveryId),
data
);
// Invalidate lists and performance metrics
queryClient.invalidateQueries({
queryKey: suppliersKeys.deliveries.lists()
});
queryClient.invalidateQueries({
queryKey: suppliersKeys.performance.all()
});
},
...options,
});
};
// Supplier Price List Mutations
export const useCreateSupplierPriceList = (
options?: UseMutationOptions<
SupplierPriceListResponse,
ApiError,
{ tenantId: string; supplierId: string; priceListData: SupplierPriceListCreate }
>
) => {
const queryClient = useQueryClient();
return useMutation<
SupplierPriceListResponse,
ApiError,
{ tenantId: string; supplierId: string; priceListData: SupplierPriceListCreate }
>({
mutationFn: ({ tenantId, supplierId, priceListData }) =>
suppliersService.createSupplierPriceList(tenantId, supplierId, priceListData),
onSuccess: (data, { tenantId, supplierId }) => {
// Add to cache
queryClient.setQueryData(
[...suppliersKeys.suppliers.detail(tenantId, supplierId), 'price-list', data.id],
data
);
// Invalidate price lists
queryClient.invalidateQueries({
queryKey: [...suppliersKeys.suppliers.detail(tenantId, supplierId), 'price-lists']
});
},
...options,
});
};
export const useUpdateSupplierPriceList = (
options?: UseMutationOptions<
SupplierPriceListResponse,
ApiError,
{ tenantId: string; supplierId: string; priceListId: string; priceListData: SupplierPriceListUpdate }
>
) => {
const queryClient = useQueryClient();
return useMutation<
SupplierPriceListResponse,
ApiError,
{ tenantId: string; supplierId: string; priceListId: string; priceListData: SupplierPriceListUpdate }
>({
mutationFn: ({ tenantId, supplierId, priceListId, priceListData }) =>
suppliersService.updateSupplierPriceList(tenantId, supplierId, priceListId, priceListData),
onSuccess: (data, { tenantId, supplierId, priceListId }) => {
// Update cache
queryClient.setQueryData(
[...suppliersKeys.suppliers.detail(tenantId, supplierId), 'price-list', priceListId],
data
);
// Invalidate price lists
queryClient.invalidateQueries({
queryKey: [...suppliersKeys.suppliers.detail(tenantId, supplierId), 'price-lists']
});
},
...options,
});
};
export const useDeleteSupplierPriceList = (
options?: UseMutationOptions<
{ message: string },
ApiError,
{ tenantId: string; supplierId: string; priceListId: string }
>
) => {
const queryClient = useQueryClient();
return useMutation<
{ message: string },
ApiError,
{ tenantId: string; supplierId: string; priceListId: string }
>({
mutationFn: ({ tenantId, supplierId, priceListId }) =>
suppliersService.deleteSupplierPriceList(tenantId, supplierId, priceListId),
onSuccess: (_, { tenantId, supplierId, priceListId }) => {
// Remove from cache
queryClient.removeQueries({
queryKey: [...suppliersKeys.suppliers.detail(tenantId, supplierId), 'price-list', priceListId]
});
// Invalidate price lists
queryClient.invalidateQueries({
queryKey: [...suppliersKeys.suppliers.detail(tenantId, supplierId), 'price-lists']
});
},
...options,
});
};
// Performance Mutations
export const useCalculateSupplierPerformance = (
options?: UseMutationOptions<
{ message: string; calculation_id: string },
ApiError,
{ tenantId: string; supplierId: string; request?: any }
>
) => {
const queryClient = useQueryClient();
return useMutation<
{ message: string; calculation_id: string },
ApiError,
{ tenantId: string; supplierId: string; request?: any }
>({
mutationFn: ({ tenantId, supplierId, request }) =>
suppliersService.calculateSupplierPerformance(tenantId, supplierId, request),
onSuccess: (_, { tenantId, supplierId }) => {
// Invalidate performance metrics after calculation
queryClient.invalidateQueries({
queryKey: suppliersKeys.performance.metrics(tenantId, supplierId)
});
queryClient.invalidateQueries({
queryKey: suppliersKeys.performance.alerts(tenantId)
});
},
...options,
});
};
export const useEvaluatePerformanceAlerts = (
options?: UseMutationOptions<
{ alerts_generated: number; message: string },
ApiError,
{ tenantId: string; supplierId?: string }
>
) => {
const queryClient = useQueryClient();
return useMutation<
{ alerts_generated: number; message: string },
ApiError,
{ tenantId: string; supplierId?: string }
>({
mutationFn: ({ tenantId, supplierId }) => suppliersService.evaluatePerformanceAlerts(tenantId, supplierId),
onSuccess: (_, { tenantId }) => {
// Invalidate performance alerts
queryClient.invalidateQueries({
queryKey: suppliersKeys.performance.alerts(tenantId)
});
},
...options,
});
};
// Utility Hooks
export const useSuppliersByStatus = (tenantId: string, status: string) => {
return useSuppliers(tenantId, { status: status as any });
};
export const useSuppliersCount = (tenantId: string) => {
const { data: statistics } = useSupplierStatistics(tenantId);
return statistics?.total_suppliers || 0;
};
export const useActiveSuppliersCount = (tenantId: string) => {
const { data: statistics } = useSupplierStatistics(tenantId);
return statistics?.active_suppliers || 0;
};
export const usePendingOrdersCount = (queryParams?: PurchaseOrderSearchParams) => {
const { data: orders } = usePurchaseOrders('', queryParams);
return orders?.length || 0;
};

View File

@@ -0,0 +1,123 @@
/**
* React Query hooks for Sustainability API
*/
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
getSustainabilityMetrics,
getSustainabilityWidgetData,
getSDGCompliance,
getEnvironmentalImpact,
exportGrantReport
} from '../services/sustainability';
import type {
SustainabilityMetrics,
SustainabilityWidgetData,
SDGCompliance,
EnvironmentalImpact,
GrantReport
} from '../types/sustainability';
// Query keys
export const sustainabilityKeys = {
all: ['sustainability'] as const,
metrics: (tenantId: string, startDate?: string, endDate?: string) =>
['sustainability', 'metrics', tenantId, startDate, endDate] as const,
widget: (tenantId: string, days: number) =>
['sustainability', 'widget', tenantId, days] as const,
sdg: (tenantId: string) =>
['sustainability', 'sdg', tenantId] as const,
environmental: (tenantId: string, days: number) =>
['sustainability', 'environmental', tenantId, days] as const,
};
/**
* Hook to get comprehensive sustainability metrics
*/
export function useSustainabilityMetrics(
tenantId: string,
startDate?: string,
endDate?: string,
options?: { enabled?: boolean }
) {
return useQuery({
queryKey: sustainabilityKeys.metrics(tenantId, startDate, endDate),
queryFn: () => getSustainabilityMetrics(tenantId, startDate, endDate),
enabled: options?.enabled !== false && !!tenantId,
staleTime: 5 * 60 * 1000, // 5 minutes
refetchInterval: 10 * 60 * 1000, // Refetch every 10 minutes
});
}
/**
* Hook to get sustainability widget data (simplified metrics)
*/
export function useSustainabilityWidget(
tenantId: string,
days: number = 30,
options?: { enabled?: boolean }
) {
return useQuery({
queryKey: sustainabilityKeys.widget(tenantId, days),
queryFn: () => getSustainabilityWidgetData(tenantId, days),
enabled: options?.enabled !== false && !!tenantId,
staleTime: 5 * 60 * 1000, // 5 minutes
refetchInterval: 10 * 60 * 1000, // Refetch every 10 minutes
});
}
/**
* Hook to get SDG 12.3 compliance status
*/
export function useSDGCompliance(
tenantId: string,
options?: { enabled?: boolean }
) {
return useQuery({
queryKey: sustainabilityKeys.sdg(tenantId),
queryFn: () => getSDGCompliance(tenantId),
enabled: options?.enabled !== false && !!tenantId,
staleTime: 10 * 60 * 1000, // 10 minutes
});
}
/**
* Hook to get environmental impact data
*/
export function useEnvironmentalImpact(
tenantId: string,
days: number = 30,
options?: { enabled?: boolean }
) {
return useQuery({
queryKey: sustainabilityKeys.environmental(tenantId, days),
queryFn: () => getEnvironmentalImpact(tenantId, days),
enabled: options?.enabled !== false && !!tenantId,
staleTime: 5 * 60 * 1000, // 5 minutes
});
}
/**
* Hook to export grant report
*/
export function useExportGrantReport() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
tenantId,
grantType,
startDate,
endDate
}: {
tenantId: string;
grantType?: string;
startDate?: string;
endDate?: string;
}) => exportGrantReport(tenantId, grantType, startDate, endDate),
onSuccess: () => {
// Optionally invalidate related queries
queryClient.invalidateQueries({ queryKey: sustainabilityKeys.all });
},
});
}

View File

@@ -0,0 +1,392 @@
/**
* Tenant React Query hooks
*/
import { useMutation, useQuery, useQueryClient, UseQueryOptions, UseMutationOptions } from '@tanstack/react-query';
import { tenantService } from '../services/tenant';
import {
BakeryRegistration,
TenantResponse,
TenantAccessResponse,
TenantUpdate,
TenantMemberResponse,
TenantStatistics,
TenantSearchParams,
TenantNearbyParams,
AddMemberWithUserCreate,
BakeryRegistrationWithSubscription,
} from '../types/tenant';
import { ApiError } from '../client';
// Query Keys
export const tenantKeys = {
all: ['tenant'] as const,
lists: () => [...tenantKeys.all, 'list'] as const,
list: (filters: string) => [...tenantKeys.lists(), { filters }] as const,
details: () => [...tenantKeys.all, 'detail'] as const,
detail: (id: string) => [...tenantKeys.details(), id] as const,
subdomain: (subdomain: string) => [...tenantKeys.all, 'subdomain', subdomain] as const,
userTenants: (userId: string) => [...tenantKeys.all, 'user', userId] as const,
userOwnedTenants: (userId: string) => [...tenantKeys.all, 'user-owned', userId] as const,
access: (tenantId: string, userId: string) => [...tenantKeys.all, 'access', tenantId, userId] as const,
search: (params: TenantSearchParams) => [...tenantKeys.lists(), 'search', params] as const,
nearby: (params: TenantNearbyParams) => [...tenantKeys.lists(), 'nearby', params] as const,
members: (tenantId: string) => [...tenantKeys.all, 'members', tenantId] as const,
statistics: () => [...tenantKeys.all, 'statistics'] as const,
} as const;
// Queries
export const useTenant = (
tenantId: string,
options?: Omit<UseQueryOptions<TenantResponse, ApiError>, 'queryKey' | 'queryFn'>
) => {
return useQuery<TenantResponse, ApiError>({
queryKey: tenantKeys.detail(tenantId),
queryFn: () => tenantService.getTenant(tenantId),
enabled: !!tenantId,
staleTime: 5 * 60 * 1000, // 5 minutes
...options,
});
};
export const useTenantBySubdomain = (
subdomain: string,
options?: Omit<UseQueryOptions<TenantResponse, ApiError>, 'queryKey' | 'queryFn'>
) => {
return useQuery<TenantResponse, ApiError>({
queryKey: tenantKeys.subdomain(subdomain),
queryFn: () => tenantService.getTenantBySubdomain(subdomain),
enabled: !!subdomain,
staleTime: 5 * 60 * 1000, // 5 minutes
...options,
});
};
export const useUserTenants = (
userId: string,
options?: Omit<UseQueryOptions<TenantResponse[], ApiError>, 'queryKey' | 'queryFn'>
) => {
return useQuery<TenantResponse[], ApiError>({
queryKey: tenantKeys.userTenants(userId),
queryFn: () => tenantService.getUserTenants(userId),
enabled: !!userId,
staleTime: 2 * 60 * 1000, // 2 minutes
...options,
});
};
export const useUserOwnedTenants = (
userId: string,
options?: Omit<UseQueryOptions<TenantResponse[], ApiError>, 'queryKey' | 'queryFn'>
) => {
return useQuery<TenantResponse[], ApiError>({
queryKey: tenantKeys.userOwnedTenants(userId),
queryFn: () => tenantService.getUserOwnedTenants(userId),
enabled: !!userId,
staleTime: 2 * 60 * 1000, // 2 minutes
...options,
});
};
export const useTenantAccess = (
tenantId: string,
userId: string,
options?: Omit<UseQueryOptions<TenantAccessResponse, ApiError>, 'queryKey' | 'queryFn'>
) => {
return useQuery<TenantAccessResponse, ApiError>({
queryKey: tenantKeys.access(tenantId, userId),
queryFn: () => tenantService.verifyTenantAccess(tenantId, userId),
enabled: !!tenantId && !!userId,
staleTime: 5 * 60 * 1000, // 5 minutes
...options,
});
};
export const useSearchTenants = (
params: TenantSearchParams,
options?: Omit<UseQueryOptions<TenantResponse[], ApiError>, 'queryKey' | 'queryFn'>
) => {
return useQuery<TenantResponse[], ApiError>({
queryKey: tenantKeys.search(params),
queryFn: () => tenantService.searchTenants(params),
enabled: !!params.search_term,
staleTime: 30 * 1000, // 30 seconds for search results
...options,
});
};
export const useNearbyTenants = (
params: TenantNearbyParams,
options?: Omit<UseQueryOptions<TenantResponse[], ApiError>, 'queryKey' | 'queryFn'>
) => {
return useQuery<TenantResponse[], ApiError>({
queryKey: tenantKeys.nearby(params),
queryFn: () => tenantService.getNearbyTenants(params),
enabled: !!(params.latitude && params.longitude),
staleTime: 5 * 60 * 1000, // 5 minutes
...options,
});
};
export const useTeamMembers = (
tenantId: string,
activeOnly: boolean = true,
options?: Omit<UseQueryOptions<TenantMemberResponse[], ApiError>, 'queryKey' | 'queryFn'>
) => {
return useQuery<TenantMemberResponse[], ApiError>({
queryKey: tenantKeys.members(tenantId),
queryFn: () => tenantService.getTeamMembers(tenantId, activeOnly),
enabled: !!tenantId,
staleTime: 2 * 60 * 1000, // 2 minutes
...options,
});
};
export const useTenantStatistics = (
options?: Omit<UseQueryOptions<TenantStatistics, ApiError>, 'queryKey' | 'queryFn'>
) => {
return useQuery<TenantStatistics, ApiError>({
queryKey: tenantKeys.statistics(),
queryFn: () => tenantService.getTenantStatistics(),
staleTime: 10 * 60 * 1000, // 10 minutes
...options,
});
};
// Mutations
export const useRegisterBakery = (
options?: UseMutationOptions<TenantResponse, ApiError, BakeryRegistration>
) => {
const queryClient = useQueryClient();
return useMutation<TenantResponse, ApiError, BakeryRegistration>({
mutationFn: (bakeryData: BakeryRegistration) => tenantService.registerBakery(bakeryData),
onSuccess: (data, variables) => {
// Invalidate user tenants to include the new one
queryClient.invalidateQueries({ queryKey: tenantKeys.userTenants('') });
queryClient.invalidateQueries({ queryKey: tenantKeys.userOwnedTenants('') });
// Set the tenant data in cache
queryClient.setQueryData(tenantKeys.detail(data.id), data);
},
...options,
});
};
export const useRegisterBakeryWithSubscription = (
options?: UseMutationOptions<TenantResponse, ApiError, BakeryRegistrationWithSubscription>
) => {
const queryClient = useQueryClient();
return useMutation<TenantResponse, ApiError, BakeryRegistrationWithSubscription>({
mutationFn: (bakeryData: BakeryRegistrationWithSubscription) => tenantService.registerBakeryWithSubscription(bakeryData),
onSuccess: (data, variables) => {
// Invalidate user tenants to include the new one
queryClient.invalidateQueries({ queryKey: tenantKeys.userTenants('') });
queryClient.invalidateQueries({ queryKey: tenantKeys.userOwnedTenants('') });
// Set the tenant data in cache
queryClient.setQueryData(tenantKeys.detail(data.id), data);
},
...options,
});
};
export const useUpdateTenant = (
options?: UseMutationOptions<TenantResponse, ApiError, { tenantId: string; updateData: TenantUpdate }>
) => {
const queryClient = useQueryClient();
return useMutation<TenantResponse, ApiError, { tenantId: string; updateData: TenantUpdate }>({
mutationFn: ({ tenantId, updateData }) => tenantService.updateTenant(tenantId, updateData),
onSuccess: (data, { tenantId }) => {
// Update the tenant cache
queryClient.setQueryData(tenantKeys.detail(tenantId), data);
// Invalidate related queries
queryClient.invalidateQueries({ queryKey: tenantKeys.userTenants('') });
queryClient.invalidateQueries({ queryKey: tenantKeys.userOwnedTenants('') });
},
...options,
});
};
export const useDeactivateTenant = (
options?: UseMutationOptions<{ success: boolean; message: string }, ApiError, string>
) => {
const queryClient = useQueryClient();
return useMutation<{ success: boolean; message: string }, ApiError, string>({
mutationFn: (tenantId: string) => tenantService.deactivateTenant(tenantId),
onSuccess: (data, tenantId) => {
// Invalidate tenant-related queries
queryClient.invalidateQueries({ queryKey: tenantKeys.detail(tenantId) });
queryClient.invalidateQueries({ queryKey: tenantKeys.userTenants('') });
queryClient.invalidateQueries({ queryKey: tenantKeys.userOwnedTenants('') });
},
...options,
});
};
export const useActivateTenant = (
options?: UseMutationOptions<{ success: boolean; message: string }, ApiError, string>
) => {
const queryClient = useQueryClient();
return useMutation<{ success: boolean; message: string }, ApiError, string>({
mutationFn: (tenantId: string) => tenantService.activateTenant(tenantId),
onSuccess: (data, tenantId) => {
// Invalidate tenant-related queries
queryClient.invalidateQueries({ queryKey: tenantKeys.detail(tenantId) });
queryClient.invalidateQueries({ queryKey: tenantKeys.userTenants('') });
queryClient.invalidateQueries({ queryKey: tenantKeys.userOwnedTenants('') });
},
...options,
});
};
export const useUpdateModelStatus = (
options?: UseMutationOptions<
TenantResponse,
ApiError,
{ tenantId: string; modelTrained: boolean; lastTrainingDate?: string }
>
) => {
const queryClient = useQueryClient();
return useMutation<
TenantResponse,
ApiError,
{ tenantId: string; modelTrained: boolean; lastTrainingDate?: string }
>({
mutationFn: ({ tenantId, modelTrained, lastTrainingDate }) =>
tenantService.updateModelStatus(tenantId, modelTrained, lastTrainingDate),
onSuccess: (data, { tenantId }) => {
// Update the tenant cache
queryClient.setQueryData(tenantKeys.detail(tenantId), data);
},
...options,
});
};
export const useAddTeamMember = (
options?: UseMutationOptions<
TenantMemberResponse,
ApiError,
{ tenantId: string; userId: string; role: string }
>
) => {
const queryClient = useQueryClient();
return useMutation<
TenantMemberResponse,
ApiError,
{ tenantId: string; userId: string; role: string }
>({
mutationFn: ({ tenantId, userId, role }) => tenantService.addTeamMember(tenantId, userId, role),
onSuccess: (data, { tenantId }) => {
// Invalidate team members query
queryClient.invalidateQueries({ queryKey: tenantKeys.members(tenantId) });
},
...options,
});
};
export const useAddTeamMemberWithUserCreation = (
options?: UseMutationOptions<
TenantMemberResponse,
ApiError,
{ tenantId: string; memberData: AddMemberWithUserCreate }
>
) => {
const queryClient = useQueryClient();
return useMutation<
TenantMemberResponse,
ApiError,
{ tenantId: string; memberData: AddMemberWithUserCreate }
>({
mutationFn: ({ tenantId, memberData }) =>
tenantService.addTeamMemberWithUserCreation(tenantId, memberData),
onSuccess: (data, { tenantId }) => {
// Invalidate team members query
queryClient.invalidateQueries({ queryKey: tenantKeys.members(tenantId) });
},
...options,
});
};
export const useUpdateMemberRole = (
options?: UseMutationOptions<
TenantMemberResponse,
ApiError,
{ tenantId: string; memberUserId: string; newRole: string }
>
) => {
const queryClient = useQueryClient();
return useMutation<
TenantMemberResponse,
ApiError,
{ tenantId: string; memberUserId: string; newRole: string }
>({
mutationFn: ({ tenantId, memberUserId, newRole }) =>
tenantService.updateMemberRole(tenantId, memberUserId, newRole),
onSuccess: (data, { tenantId }) => {
// Invalidate team members query
queryClient.invalidateQueries({ queryKey: tenantKeys.members(tenantId) });
},
...options,
});
};
export const useRemoveTeamMember = (
options?: UseMutationOptions<
{ success: boolean; message: string },
ApiError,
{ tenantId: string; memberUserId: string }
>
) => {
const queryClient = useQueryClient();
return useMutation<
{ success: boolean; message: string },
ApiError,
{ tenantId: string; memberUserId: string }
>({
mutationFn: ({ tenantId, memberUserId }) => tenantService.removeTeamMember(tenantId, memberUserId),
onSuccess: (data, { tenantId }) => {
// Invalidate team members query
queryClient.invalidateQueries({ queryKey: tenantKeys.members(tenantId) });
},
...options,
});
};
/**
* Hook to transfer tenant ownership to another admin
* This is a critical operation that changes the tenant owner
*/
export const useTransferOwnership = (
options?: UseMutationOptions<
TenantResponse,
ApiError,
{ tenantId: string; newOwnerId: string }
>
) => {
const queryClient = useQueryClient();
return useMutation<
TenantResponse,
ApiError,
{ tenantId: string; newOwnerId: string }
>({
mutationFn: ({ tenantId, newOwnerId }) => tenantService.transferOwnership(tenantId, newOwnerId),
onSuccess: (data, { tenantId }) => {
// Invalidate all tenant-related queries since ownership changed
queryClient.invalidateQueries({ queryKey: tenantKeys.detail(tenantId) });
queryClient.invalidateQueries({ queryKey: tenantKeys.members(tenantId) });
queryClient.invalidateQueries({ queryKey: tenantKeys.userTenants('') });
queryClient.invalidateQueries({ queryKey: tenantKeys.userOwnedTenants('') });
// Invalidate access queries for all users since roles changed
queryClient.invalidateQueries({ queryKey: tenantKeys.access(tenantId, '') });
},
...options,
});
};

View File

@@ -0,0 +1,707 @@
/**
* Training React Query hooks
* Provides data fetching, caching, and state management for training operations
*/
import * as React from 'react';
import { useMutation, useQuery, useQueryClient, UseQueryOptions, UseMutationOptions } from '@tanstack/react-query';
import { trainingService } from '../services/training';
import { ApiError, apiClient } from '../client/apiClient';
import { useAuthStore } from '../../stores/auth.store';
import {
HTTP_POLLING_INTERVAL_MS,
HTTP_POLLING_DEBOUNCE_MS,
WEBSOCKET_HEARTBEAT_INTERVAL_MS,
WEBSOCKET_MAX_RECONNECT_ATTEMPTS,
WEBSOCKET_RECONNECT_INITIAL_DELAY_MS,
WEBSOCKET_RECONNECT_MAX_DELAY_MS,
PROGRESS_DATA_ANALYSIS,
PROGRESS_TRAINING_RANGE_START,
PROGRESS_TRAINING_RANGE_END
} from '../../constants/training';
import type {
TrainingJobRequest,
TrainingJobResponse,
TrainingJobStatus,
SingleProductTrainingRequest,
ModelMetricsResponse,
TrainedModelResponse,
} from '../types/training';
// Query Keys Factory
export const trainingKeys = {
all: ['training'] as const,
jobs: {
all: () => [...trainingKeys.all, 'jobs'] as const,
status: (tenantId: string, jobId: string) =>
[...trainingKeys.jobs.all(), 'status', tenantId, jobId] as const,
},
models: {
all: () => [...trainingKeys.all, 'models'] as const,
lists: () => [...trainingKeys.models.all(), 'list'] as const,
list: (tenantId: string, params?: any) =>
[...trainingKeys.models.lists(), tenantId, params] as const,
details: () => [...trainingKeys.models.all(), 'detail'] as const,
detail: (tenantId: string, modelId: string) =>
[...trainingKeys.models.details(), tenantId, modelId] as const,
active: (tenantId: string, inventoryProductId: string) =>
[...trainingKeys.models.all(), 'active', tenantId, inventoryProductId] as const,
metrics: (tenantId: string, modelId: string) =>
[...trainingKeys.models.all(), 'metrics', tenantId, modelId] as const,
performance: (tenantId: string, modelId: string) =>
[...trainingKeys.models.all(), 'performance', tenantId, modelId] as const,
},
statistics: (tenantId: string) =>
[...trainingKeys.all, 'statistics', tenantId] as const,
} as const;
// Training Job Queries
export const useTrainingJobStatus = (
tenantId: string,
jobId: string,
options?: Omit<UseQueryOptions<TrainingJobStatus, ApiError>, 'queryKey' | 'queryFn'> & {
isWebSocketConnected?: boolean;
}
) => {
const { isWebSocketConnected, ...queryOptions } = options || {};
const [enablePolling, setEnablePolling] = React.useState(false);
// Debounce HTTP polling activation: wait after WebSocket disconnects
// This prevents race conditions where both WebSocket and HTTP are briefly active
React.useEffect(() => {
if (!isWebSocketConnected) {
const debounceTimer = setTimeout(() => {
setEnablePolling(true);
console.log(`🔄 HTTP polling enabled after ${HTTP_POLLING_DEBOUNCE_MS}ms debounce (WebSocket disconnected)`);
}, HTTP_POLLING_DEBOUNCE_MS);
return () => clearTimeout(debounceTimer);
} else {
setEnablePolling(false);
console.log('❌ HTTP polling disabled (WebSocket connected)');
}
}, [isWebSocketConnected]);
// Completely disable the query when WebSocket is connected or during debounce period
const isEnabled = !!tenantId && !!jobId && !isWebSocketConnected && enablePolling;
console.log('🔄 Training status query:', {
tenantId: !!tenantId,
jobId: !!jobId,
isWebSocketConnected,
enablePolling,
queryEnabled: isEnabled
});
return useQuery<TrainingJobStatus, ApiError>({
queryKey: trainingKeys.jobs.status(tenantId, jobId),
queryFn: () => {
console.log('📡 Executing HTTP training status query (WebSocket disconnected)');
return trainingService.getTrainingJobStatus(tenantId, jobId);
},
enabled: isEnabled, // Completely disable when WebSocket connected
refetchInterval: isEnabled ? (query) => {
// Only set up refetch interval if the query is enabled
const data = query.state.data;
// Stop polling if we get auth errors or training is completed
if (query.state.error && (query.state.error as any)?.status === 401) {
console.log('🚫 Stopping status polling due to auth error');
return false;
}
if (data?.status === 'completed' || data?.status === 'failed') {
console.log('🏁 Training completed - stopping HTTP polling');
return false; // Stop polling when training is done
}
console.log(`📊 HTTP fallback polling active (WebSocket disconnected) - ${HTTP_POLLING_INTERVAL_MS}ms interval`);
return HTTP_POLLING_INTERVAL_MS; // Poll while training (fallback when WebSocket unavailable)
} : false, // Completely disable interval when WebSocket connected
staleTime: 1000, // Consider data stale after 1 second
retry: (failureCount, error) => {
// Don't retry on auth errors
if ((error as any)?.status === 401) {
console.log('🚫 Not retrying due to auth error');
return false;
}
return failureCount < 3;
},
...queryOptions,
});
};
// Model Queries
export const useActiveModel = (
tenantId: string,
inventoryProductId: string,
options?: Omit<UseQueryOptions<any, ApiError>, 'queryKey' | 'queryFn'>
) => {
return useQuery<any, ApiError>({
queryKey: trainingKeys.models.active(tenantId, inventoryProductId),
queryFn: () => trainingService.getActiveModel(tenantId, inventoryProductId),
enabled: !!tenantId && !!inventoryProductId,
staleTime: 5 * 60 * 1000, // 5 minutes
...options,
});
};
export const useModels = (
tenantId: string,
queryParams?: any,
options?: Omit<UseQueryOptions<any, ApiError>, 'queryKey' | 'queryFn'>
) => {
return useQuery<any, ApiError>({
queryKey: trainingKeys.models.list(tenantId, queryParams),
queryFn: () => trainingService.getModels(tenantId, queryParams),
enabled: !!tenantId,
staleTime: 2 * 60 * 1000, // 2 minutes
...options,
});
};
export const useModelMetrics = (
tenantId: string,
modelId: string,
options?: Omit<UseQueryOptions<ModelMetricsResponse, ApiError>, 'queryKey' | 'queryFn'>
) => {
return useQuery<ModelMetricsResponse, ApiError>({
queryKey: trainingKeys.models.metrics(tenantId, modelId),
queryFn: () => trainingService.getModelMetrics(tenantId, modelId),
enabled: !!tenantId && !!modelId,
staleTime: 10 * 60 * 1000, // 10 minutes
...options,
});
};
export const useModelPerformance = (
tenantId: string,
modelId: string,
options?: Omit<UseQueryOptions<any, ApiError>, 'queryKey' | 'queryFn'>
) => {
return useQuery<any, ApiError>({
queryKey: trainingKeys.models.performance(tenantId, modelId),
queryFn: () => trainingService.getModelPerformance(tenantId, modelId),
enabled: !!tenantId && !!modelId,
staleTime: 10 * 60 * 1000, // 10 minutes
...options,
});
};
// Statistics Queries
export const useTenantTrainingStatistics = (
tenantId: string,
options?: Omit<UseQueryOptions<any, ApiError>, 'queryKey' | 'queryFn'>
) => {
return useQuery<any, ApiError>({
queryKey: trainingKeys.statistics(tenantId),
queryFn: () => trainingService.getTenantStatistics(tenantId),
enabled: !!tenantId,
staleTime: 5 * 60 * 1000, // 5 minutes
...options,
});
};
// Training Job Mutations
export const useCreateTrainingJob = (
options?: UseMutationOptions<
TrainingJobResponse,
ApiError,
{ tenantId: string; request: TrainingJobRequest }
>
) => {
const queryClient = useQueryClient();
return useMutation<
TrainingJobResponse,
ApiError,
{ tenantId: string; request: TrainingJobRequest }
>({
mutationFn: ({ tenantId, request }) => trainingService.createTrainingJob(tenantId, request),
onSuccess: (data, { tenantId }) => {
// Add the job status to cache
queryClient.setQueryData(
trainingKeys.jobs.status(tenantId, data.job_id),
{
job_id: data.job_id,
status: data.status,
progress: 0,
}
);
// Invalidate statistics to reflect the new training job
queryClient.invalidateQueries({ queryKey: trainingKeys.statistics(tenantId) });
},
...options,
});
};
export const useTrainSingleProduct = (
options?: UseMutationOptions<
TrainingJobResponse,
ApiError,
{ tenantId: string; inventoryProductId: string; request: SingleProductTrainingRequest }
>
) => {
const queryClient = useQueryClient();
return useMutation<
TrainingJobResponse,
ApiError,
{ tenantId: string; inventoryProductId: string; request: SingleProductTrainingRequest }
>({
mutationFn: ({ tenantId, inventoryProductId, request }) =>
trainingService.trainSingleProduct(tenantId, inventoryProductId, request),
onSuccess: (data, { tenantId, inventoryProductId }) => {
// Add the job status to cache
queryClient.setQueryData(
trainingKeys.jobs.status(tenantId, data.job_id),
{
job_id: data.job_id,
status: data.status,
progress: 0,
}
);
// Invalidate active model for this product
queryClient.invalidateQueries({
queryKey: trainingKeys.models.active(tenantId, inventoryProductId)
});
// Invalidate statistics
queryClient.invalidateQueries({ queryKey: trainingKeys.statistics(tenantId) });
},
...options,
});
};
// Admin Mutations
export const useDeleteAllTenantModels = (
options?: UseMutationOptions<{ message: string }, ApiError, { tenantId: string }>
) => {
const queryClient = useQueryClient();
return useMutation<{ message: string }, ApiError, { tenantId: string }>({
mutationFn: ({ tenantId }) => trainingService.deleteAllTenantModels(tenantId),
onSuccess: (_, { tenantId }) => {
// Invalidate all model-related queries for this tenant
queryClient.invalidateQueries({ queryKey: trainingKeys.models.all() });
queryClient.invalidateQueries({ queryKey: trainingKeys.statistics(tenantId) });
},
...options,
});
};
// WebSocket Hook for Real-time Training Updates
export const useTrainingWebSocket = (
tenantId: string,
jobId: string,
token?: string,
options?: {
onProgress?: (data: any) => void;
onCompleted?: (data: any) => void;
onError?: (error: any) => void;
onStarted?: (data: any) => void;
onCancelled?: (data: any) => void;
}
) => {
const queryClient = useQueryClient();
const [isConnected, setIsConnected] = React.useState(false);
const [connectionError, setConnectionError] = React.useState<string | null>(null);
const [connectionAttempts, setConnectionAttempts] = React.useState(0);
// Memoize options to prevent unnecessary effect re-runs
const memoizedOptions = React.useMemo(() => options, [
options?.onProgress,
options?.onCompleted,
options?.onError,
options?.onStarted,
options?.onCancelled
]);
React.useEffect(() => {
if (!tenantId || !jobId || !memoizedOptions) {
return;
}
let ws: WebSocket | null = null;
let reconnectTimer: NodeJS.Timeout | null = null;
let isManuallyDisconnected = false;
let reconnectAttempts = 0;
const maxReconnectAttempts = WEBSOCKET_MAX_RECONNECT_ATTEMPTS;
const connect = async () => {
try {
setConnectionError(null);
setConnectionAttempts(prev => prev + 1);
// Use centralized token management from apiClient
let effectiveToken: string | null;
try {
// Always use the apiClient's token management
effectiveToken = await apiClient.ensureValidToken();
if (!effectiveToken) {
throw new Error('No valid token available');
}
} catch (error) {
console.error('❌ Failed to get valid token for WebSocket:', error);
setConnectionError('Authentication failed. Please log in again.');
return;
}
console.log(`🔄 Attempting WebSocket connection (attempt ${reconnectAttempts + 1}/${maxReconnectAttempts + 1}):`, {
tenantId,
jobId,
hasToken: !!effectiveToken,
tokenFromApiClient: true
});
ws = trainingService.createWebSocketConnection(tenantId, jobId, effectiveToken);
ws.onopen = () => {
console.log('✅ Training WebSocket connected successfully', {
readyState: ws?.readyState,
url: ws?.url,
jobId
});
// Track connection time for debugging
(ws as any)._connectTime = Date.now();
setIsConnected(true);
reconnectAttempts = 0; // Reset on successful connection
// Request current status on connection
try {
ws?.send('get_status');
console.log('📤 Requested current training status');
} catch (e) {
console.warn('Failed to request status on connection:', e);
}
// Helper function to check if tokens represent different auth users/sessions
const isNewAuthSession = (oldToken: string, newToken: string): boolean => {
if (!oldToken || !newToken) return !!oldToken !== !!newToken;
try {
const oldPayload = JSON.parse(atob(oldToken.split('.')[1]));
const newPayload = JSON.parse(atob(newToken.split('.')[1]));
// Compare by user ID - different user means new auth session
// If user_id is same, it's just a token refresh, no need to reconnect
return oldPayload.user_id !== newPayload.user_id ||
oldPayload.sub !== newPayload.sub;
} catch (e) {
console.warn('Failed to parse token for session comparison:', e);
// On parse error, don't reconnect (assume same session)
return false;
}
};
// Set up periodic ping and check for auth session changes
const heartbeatInterval = setInterval(async () => {
if (ws?.readyState === WebSocket.OPEN && !isManuallyDisconnected) {
try {
// Check token validity (this may refresh if needed)
const currentToken = await apiClient.ensureValidToken();
// Only reconnect if user changed (new auth session)
if (currentToken && effectiveToken && isNewAuthSession(effectiveToken, currentToken)) {
console.log('🔄 Auth session changed (different user) - reconnecting WebSocket');
ws?.close(1000, 'Auth session changed - reconnecting');
clearInterval(heartbeatInterval);
return;
}
// Token may have been refreshed but it's the same user - continue
if (currentToken && currentToken !== effectiveToken) {
console.log(' Token refreshed (same user) - updating reference');
effectiveToken = currentToken;
}
// Send ping
ws?.send('ping');
console.log('💓 Sent ping to server');
} catch (e) {
console.warn('Failed to send ping or validate token:', e);
clearInterval(heartbeatInterval);
}
} else {
clearInterval(heartbeatInterval);
}
}, WEBSOCKET_HEARTBEAT_INTERVAL_MS); // Check for auth changes and send ping
// Store interval for cleanup
(ws as any).heartbeatInterval = heartbeatInterval;
};
ws.onmessage = (event) => {
try {
// Handle non-JSON messages (like pong responses)
if (typeof event.data === 'string' && event.data === 'pong') {
console.log('🏓 Pong received from server');
return;
}
const message = JSON.parse(event.data);
console.log('🔔 Training WebSocket message received:', message);
// Handle initial state message to restore the latest known state
if (message.type === 'initial_state') {
console.log('📥 Received initial state:', message.data);
const initialData = message.data;
const initialEventData = initialData.data || {};
let initialProgress = initialEventData.progress || 0;
// Calculate progress for product_completed events
if (initialData.type === 'product_completed') {
const productsCompleted = initialEventData.products_completed || 0;
const totalProducts = initialEventData.total_products || 1;
const trainingRangeWidth = PROGRESS_TRAINING_RANGE_END - PROGRESS_DATA_ANALYSIS;
initialProgress = PROGRESS_DATA_ANALYSIS + Math.floor((productsCompleted / totalProducts) * trainingRangeWidth);
console.log('📦 Product training completed in initial state',
`${productsCompleted}/${totalProducts}`,
`progress: ${initialProgress}%`);
}
// Update job status in cache with initial state
queryClient.setQueryData(
trainingKeys.jobs.status(tenantId, jobId),
(oldData: TrainingJobStatus | undefined) => ({
...oldData,
job_id: jobId,
status: initialData.type === 'completed' ? 'completed' :
initialData.type === 'failed' ? 'failed' :
initialData.type === 'started' ? 'running' :
initialData.type === 'progress' ? 'running' :
initialData.type === 'product_completed' ? 'running' :
initialData.type === 'step_completed' ? 'running' :
oldData?.status || 'running',
progress: typeof initialProgress === 'number' ? initialProgress : oldData?.progress || 0,
current_step: initialEventData.current_step || initialEventData.step_name || oldData?.current_step,
})
);
return; // Initial state messages are only for state restoration, don't process as regular events
}
// Extract data from backend message structure
const eventData = message.data || {};
let progress = eventData.progress || 0;
const currentStep = eventData.current_step || eventData.step_name || '';
const stepDetails = eventData.step_details || '';
// Handle product_completed events - calculate progress dynamically
if (message.type === 'product_completed') {
const productsCompleted = eventData.products_completed || 0;
const totalProducts = eventData.total_products || 1;
// Calculate progress: DATA_ANALYSIS% base + (completed/total * (TRAINING_RANGE_END - DATA_ANALYSIS)%)
const trainingRangeWidth = PROGRESS_TRAINING_RANGE_END - PROGRESS_DATA_ANALYSIS;
progress = PROGRESS_DATA_ANALYSIS + Math.floor((productsCompleted / totalProducts) * trainingRangeWidth);
console.log('📦 Product training completed',
`${productsCompleted}/${totalProducts}`,
`progress: ${progress}%`);
}
// Update job status in cache
queryClient.setQueryData(
trainingKeys.jobs.status(tenantId, jobId),
(oldData: TrainingJobStatus | undefined) => ({
...oldData,
job_id: jobId,
status: message.type === 'completed' ? 'completed' :
message.type === 'failed' ? 'failed' :
message.type === 'started' ? 'running' :
oldData?.status || 'running',
progress: typeof progress === 'number' ? progress : oldData?.progress || 0,
current_step: currentStep || oldData?.current_step,
})
);
// Call appropriate callback based on message type
switch (message.type) {
case 'connected':
console.log('🔗 WebSocket connected');
break;
case 'started':
console.log('🚀 Training started');
memoizedOptions?.onStarted?.(message);
break;
case 'progress':
console.log('📊 Training progress update', `${progress}%`);
memoizedOptions?.onProgress?.(message);
break;
case 'product_completed':
console.log('✅ Product training completed');
// Treat as progress update
memoizedOptions?.onProgress?.({
...message,
data: {
...eventData,
progress, // Use calculated progress
}
});
break;
case 'step_completed':
console.log('📋 Step completed');
memoizedOptions?.onProgress?.(message);
break;
case 'completed':
console.log('✅ Training completed successfully');
memoizedOptions?.onCompleted?.(message);
// Invalidate models and statistics
queryClient.invalidateQueries({ queryKey: trainingKeys.models.all() });
queryClient.invalidateQueries({ queryKey: trainingKeys.statistics(tenantId) });
isManuallyDisconnected = true;
break;
case 'failed':
console.log('❌ Training failed');
memoizedOptions?.onError?.(message);
isManuallyDisconnected = true;
break;
default:
console.log(`🔍 Unknown message type: ${message.type}`);
break;
}
} catch (error) {
console.error('Error parsing WebSocket message:', error);
setConnectionError('Error parsing message from server');
}
};
ws.onerror = (error) => {
console.error('Training WebSocket error:', error);
setConnectionError('WebSocket connection error');
setIsConnected(false);
};
ws.onclose = (event) => {
console.log(`❌ Training WebSocket disconnected. Code: ${event.code}, Reason: "${event.reason}"`, {
wasClean: event.wasClean,
jobId,
timeConnected: ws ? `${Date.now() - (ws as any)._connectTime || 0}ms` : 'unknown',
reconnectAttempts
});
setIsConnected(false);
// Detailed logging for different close codes
switch (event.code) {
case 1000:
if (event.reason === 'Auth session changed - reconnecting') {
console.log('🔄 WebSocket closed for auth session change - will reconnect immediately');
} else {
console.log('🔒 WebSocket closed normally');
}
break;
case 1006:
console.log('⚠️ WebSocket closed abnormally (1006) - likely server-side issue or network problem');
break;
case 1001:
console.log('🔄 WebSocket endpoint going away');
break;
case 1003:
console.log('❌ WebSocket unsupported data received');
break;
default:
console.log(`❓ WebSocket closed with code ${event.code}`);
}
// Handle auth session change reconnection (immediate reconnect)
if (event.code === 1000 && event.reason === 'Auth session changed - reconnecting') {
console.log('🔄 Reconnecting immediately due to auth session change...');
reconnectTimer = setTimeout(() => {
connect(); // Reconnect immediately with new session token
}, WEBSOCKET_RECONNECT_INITIAL_DELAY_MS); // Short delay to allow cleanup
return;
}
// Try to reconnect if not manually disconnected and haven't exceeded max attempts
if (!isManuallyDisconnected && event.code !== 1000 && reconnectAttempts < maxReconnectAttempts) {
const delay = Math.min(
WEBSOCKET_RECONNECT_INITIAL_DELAY_MS * Math.pow(2, reconnectAttempts),
WEBSOCKET_RECONNECT_MAX_DELAY_MS
); // Exponential backoff
console.log(`🔄 Attempting to reconnect WebSocket in ${delay/1000}s... (attempt ${reconnectAttempts + 1}/${maxReconnectAttempts})`);
reconnectTimer = setTimeout(() => {
reconnectAttempts++;
connect();
}, delay);
} else if (reconnectAttempts >= maxReconnectAttempts) {
console.log(`❌ Max reconnection attempts (${maxReconnectAttempts}) reached. Giving up.`);
setConnectionError(`Connection failed after ${maxReconnectAttempts} attempts. The training job may not exist or the server may be unavailable.`);
}
};
} catch (error) {
console.error('Error creating WebSocket connection:', error);
setConnectionError('Failed to create WebSocket connection');
}
};
// Connect immediately to avoid missing early progress updates
console.log('🚀 Starting immediate WebSocket connection...');
connect();
// Cleanup function
return () => {
isManuallyDisconnected = true;
if (reconnectTimer) {
clearTimeout(reconnectTimer);
}
if (ws) {
ws.close(1000, 'Component unmounted');
}
setIsConnected(false);
};
}, [tenantId, jobId, queryClient, memoizedOptions]);
return {
isConnected,
connectionError
};
};
// Utility Hooks
export const useIsTrainingInProgress = (
tenantId: string,
jobId?: string,
isWebSocketConnected?: boolean
) => {
const { data: jobStatus } = useTrainingJobStatus(tenantId, jobId || '', {
enabled: !!jobId,
isWebSocketConnected,
});
return jobStatus?.status === 'running' || jobStatus?.status === 'pending';
};
export const useTrainingProgress = (
tenantId: string,
jobId?: string,
isWebSocketConnected?: boolean
) => {
const { data: jobStatus } = useTrainingJobStatus(tenantId, jobId || '', {
enabled: !!jobId,
isWebSocketConnected,
});
return {
progress: jobStatus?.progress || 0,
currentStep: jobStatus?.current_step,
isComplete: jobStatus?.status === 'completed',
isFailed: jobStatus?.status === 'failed',
isRunning: jobStatus?.status === 'running',
};
};

View File

@@ -0,0 +1,354 @@
/**
* Clean React Query Hooks for Alert System
*
* NO backward compatibility, uses new type system and alert service
*/
import { useQuery, useMutation, useQueryClient, UseQueryOptions, UseMutationOptions } from '@tanstack/react-query';
import type {
EventResponse,
Alert,
PaginatedResponse,
EventsSummary,
EventQueryParams,
} from '../types/events';
import {
getEvents,
getEvent,
getEventsSummary,
acknowledgeAlert,
resolveAlert,
cancelAutoAction,
dismissRecommendation,
recordInteraction,
acknowledgeAlertsByMetadata,
resolveAlertsByMetadata,
type AcknowledgeAlertResponse,
type ResolveAlertResponse,
type CancelAutoActionResponse,
type DismissRecommendationResponse,
type BulkAcknowledgeResponse,
type BulkResolveResponse,
} from '../services/alertService';
// ============================================================
// QUERY KEYS
// ============================================================
export const alertKeys = {
all: ['alerts'] as const,
lists: () => [...alertKeys.all, 'list'] as const,
list: (tenantId: string, params?: EventQueryParams) =>
[...alertKeys.lists(), tenantId, params] as const,
details: () => [...alertKeys.all, 'detail'] as const,
detail: (tenantId: string, eventId: string) =>
[...alertKeys.details(), tenantId, eventId] as const,
summaries: () => [...alertKeys.all, 'summary'] as const,
summary: (tenantId: string) => [...alertKeys.summaries(), tenantId] as const,
};
// ============================================================
// QUERY HOOKS
// ============================================================
/**
* Fetch events list with filtering and pagination
*/
export function useEvents(
tenantId: string,
params?: EventQueryParams,
options?: Omit<
UseQueryOptions<PaginatedResponse<EventResponse>, Error>,
'queryKey' | 'queryFn'
>
) {
return useQuery<PaginatedResponse<EventResponse>, Error>({
queryKey: alertKeys.list(tenantId, params),
queryFn: () => getEvents(tenantId, params),
enabled: !!tenantId,
staleTime: 30 * 1000, // 30 seconds
...options,
});
}
/**
* Fetch single event by ID
*/
export function useEvent(
tenantId: string,
eventId: string,
options?: Omit<UseQueryOptions<EventResponse, Error>, 'queryKey' | 'queryFn'>
) {
return useQuery<EventResponse, Error>({
queryKey: alertKeys.detail(tenantId, eventId),
queryFn: () => getEvent(tenantId, eventId),
enabled: !!tenantId && !!eventId,
staleTime: 60 * 1000, // 1 minute
...options,
});
}
/**
* Fetch events summary for dashboard
*/
export function useEventsSummary(
tenantId: string,
options?: Omit<UseQueryOptions<EventsSummary, Error>, 'queryKey' | 'queryFn'>
) {
return useQuery<EventsSummary, Error>({
queryKey: alertKeys.summary(tenantId),
queryFn: () => getEventsSummary(tenantId),
enabled: !!tenantId,
staleTime: 30 * 1000, // 30 seconds
refetchInterval: 60 * 1000, // Refetch every minute
...options,
});
}
// ============================================================
// MUTATION HOOKS - Alerts
// ============================================================
interface UseAcknowledgeAlertOptions {
tenantId: string;
options?: UseMutationOptions<AcknowledgeAlertResponse, Error, string>;
}
/**
* Acknowledge an alert
*/
export function useAcknowledgeAlert({
tenantId,
options,
}: UseAcknowledgeAlertOptions) {
const queryClient = useQueryClient();
return useMutation<AcknowledgeAlertResponse, Error, string>({
mutationFn: (alertId: string) => acknowledgeAlert(tenantId, alertId),
onSuccess: (data, alertId) => {
// Invalidate relevant queries
queryClient.invalidateQueries({ queryKey: alertKeys.lists() });
queryClient.invalidateQueries({ queryKey: alertKeys.summaries() });
queryClient.invalidateQueries({
queryKey: alertKeys.detail(tenantId, alertId),
});
// Call user's onSuccess if provided (passing the context as well)
if (options?.onSuccess) {
options.onSuccess(data, alertId, {} as any);
}
},
...options,
});
}
interface UseResolveAlertOptions {
tenantId: string;
options?: UseMutationOptions<ResolveAlertResponse, Error, string>;
}
/**
* Resolve an alert
*/
export function useResolveAlert({ tenantId, options }: UseResolveAlertOptions) {
const queryClient = useQueryClient();
return useMutation<ResolveAlertResponse, Error, string>({
mutationFn: (alertId: string) => resolveAlert(tenantId, alertId),
onSuccess: (data, alertId) => {
// Invalidate relevant queries
queryClient.invalidateQueries({ queryKey: alertKeys.lists() });
queryClient.invalidateQueries({ queryKey: alertKeys.summaries() });
queryClient.invalidateQueries({
queryKey: alertKeys.detail(tenantId, alertId),
});
// Call user's onSuccess if provided (passing the context as well)
if (options?.onSuccess) {
options.onSuccess(data, alertId, {} as any);
}
},
...options,
});
}
interface UseCancelAutoActionOptions {
tenantId: string;
options?: UseMutationOptions<CancelAutoActionResponse, Error, string>;
}
/**
* Cancel an alert's auto-action (escalation countdown)
*/
export function useCancelAutoAction({
tenantId,
options,
}: UseCancelAutoActionOptions) {
const queryClient = useQueryClient();
return useMutation<CancelAutoActionResponse, Error, string>({
mutationFn: (alertId: string) => cancelAutoAction(tenantId, alertId),
onSuccess: (data, alertId) => {
// Invalidate relevant queries
queryClient.invalidateQueries({ queryKey: alertKeys.lists() });
queryClient.invalidateQueries({
queryKey: alertKeys.detail(tenantId, alertId),
});
// Call user's onSuccess if provided (passing the context as well)
if (options?.onSuccess) {
options.onSuccess(data, alertId, {} as any);
}
},
...options,
});
}
// ============================================================
// MUTATION HOOKS - Recommendations
// ============================================================
interface UseDismissRecommendationOptions {
tenantId: string;
options?: UseMutationOptions<DismissRecommendationResponse, Error, string>;
}
/**
* Dismiss a recommendation
*/
export function useDismissRecommendation({
tenantId,
options,
}: UseDismissRecommendationOptions) {
const queryClient = useQueryClient();
return useMutation<DismissRecommendationResponse, Error, string>({
mutationFn: (recommendationId: string) =>
dismissRecommendation(tenantId, recommendationId),
onSuccess: (data, recommendationId) => {
// Invalidate relevant queries
queryClient.invalidateQueries({ queryKey: alertKeys.lists() });
queryClient.invalidateQueries({ queryKey: alertKeys.summaries() });
queryClient.invalidateQueries({
queryKey: alertKeys.detail(tenantId, recommendationId),
});
// Call user's onSuccess if provided
options?.onSuccess?.(data, recommendationId, undefined);
},
...options,
});
}
// ============================================================
// MUTATION HOOKS - Bulk Operations
// ============================================================
interface UseBulkAcknowledgeOptions {
tenantId: string;
options?: UseMutationOptions<
BulkAcknowledgeResponse,
Error,
{ alertType: string; metadataFilter: Record<string, any> }
>;
}
/**
* Acknowledge multiple alerts by metadata
*/
export function useBulkAcknowledgeAlerts({
tenantId,
options,
}: UseBulkAcknowledgeOptions) {
const queryClient = useQueryClient();
return useMutation<
BulkAcknowledgeResponse,
Error,
{ alertType: string; metadataFilter: Record<string, any> }
>({
mutationFn: ({ alertType, metadataFilter }) =>
acknowledgeAlertsByMetadata(tenantId, alertType, metadataFilter),
onSuccess: (data, variables) => {
// Invalidate all alert queries
queryClient.invalidateQueries({ queryKey: alertKeys.lists() });
queryClient.invalidateQueries({ queryKey: alertKeys.summaries() });
// Call user's onSuccess if provided (passing the context as well)
if (options?.onSuccess) {
options.onSuccess(data, variables, {} as any);
}
},
...options,
});
}
interface UseBulkResolveOptions {
tenantId: string;
options?: UseMutationOptions<
BulkResolveResponse,
Error,
{ alertType: string; metadataFilter: Record<string, any> }
>;
}
/**
* Resolve multiple alerts by metadata
*/
export function useBulkResolveAlerts({
tenantId,
options,
}: UseBulkResolveOptions) {
const queryClient = useQueryClient();
return useMutation<
BulkResolveResponse,
Error,
{ alertType: string; metadataFilter: Record<string, any> }
>({
mutationFn: ({ alertType, metadataFilter }) =>
resolveAlertsByMetadata(tenantId, alertType, metadataFilter),
onSuccess: (data, variables) => {
// Invalidate all alert queries
queryClient.invalidateQueries({ queryKey: alertKeys.lists() });
queryClient.invalidateQueries({ queryKey: alertKeys.summaries() });
// Call user's onSuccess if provided (passing the context as well)
if (options?.onSuccess) {
options.onSuccess(data, variables, {} as any);
}
},
...options,
});
}
// ============================================================
// MUTATION HOOKS - Interaction Tracking
// ============================================================
interface UseRecordInteractionOptions {
tenantId: string;
options?: UseMutationOptions<
any,
Error,
{ eventId: string; interactionType: string; metadata?: Record<string, any> }
>;
}
/**
* Record user interaction with an event
*/
export function useRecordInteraction({
tenantId,
options,
}: UseRecordInteractionOptions) {
return useMutation<
any,
Error,
{ eventId: string; interactionType: string; metadata?: Record<string, any> }
>({
mutationFn: ({ eventId, interactionType, metadata }) =>
recordInteraction(tenantId, eventId, interactionType, metadata),
...options,
});
}

View File

@@ -0,0 +1,526 @@
/**
* Enhanced Control Panel Data Hook
*
* Handles initial API fetch, SSE integration, and data merging with priority rules
* for the control panel page.
*/
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useEffect, useState, useCallback, useRef } from 'react';
import { alertService } from '../services/alertService';
import { getPendingApprovalPurchaseOrders } from '../services/purchase_orders';
import { productionService } from '../services/production';
import { ProcurementService } from '../services/procurement-service';
import * as orchestratorService from '../services/orchestrator';
import { suppliersService } from '../services/suppliers';
import { aiInsightsService } from '../services/aiInsights';
import { useSSEEvents } from '../../hooks/useSSE';
import { parseISO } from 'date-fns';
// Debounce delay for SSE-triggered query invalidations (ms)
const SSE_INVALIDATION_DEBOUNCE_MS = 500;
// Delay before SSE invalidations are allowed after initial load (ms)
// This prevents duplicate API calls when SSE events arrive during/right after initial fetch
const SSE_INITIAL_LOAD_GRACE_PERIOD_MS = 3000;
// ============================================================
// Types
// ============================================================
export interface ControlPanelData {
// Raw data from APIs
alerts: any[];
pendingPOs: any[];
productionBatches: any[];
deliveries: any[];
orchestrationSummary: OrchestrationSummary | null;
aiInsights: any[];
// Computed/derived data
preventedIssues: any[];
issuesRequiringAction: number;
issuesPreventedByAI: number;
// Filtered data for blocks
overdueDeliveries: any[];
pendingDeliveries: any[];
lateToStartBatches: any[];
runningBatches: any[];
pendingBatches: any[];
// Categorized alerts
equipmentAlerts: any[];
productionAlerts: any[];
otherAlerts: any[];
}
export interface OrchestrationSummary {
runTimestamp: string | null;
runNumber?: number;
status: string;
purchaseOrdersCreated: number;
productionBatchesCreated: number;
userActionsRequired: number;
aiHandlingRate?: number;
estimatedSavingsEur?: number;
}
// ============================================================
// Data Priority and Merging Logic
// ============================================================
/**
* Merge data with priority rules:
* 1. Services API data takes precedence
* 2. Alerts data enriches services data
* 3. Alerts data is used as fallback when no services data exists
* 4. Deduplicate alerts for entities already shown in UI
*/
function mergeDataWithPriority(
servicesData: any,
alertsData: any,
entityType: 'po' | 'batch' | 'delivery'
): any[] {
const mergedEntities = [...servicesData];
const servicesEntityIds = new Set(servicesData.map((entity: any) => entity.id));
// Enrich services data with alerts data
const enrichedEntities = mergedEntities.map(entity => {
const matchingAlert = alertsData.find((alert: any) =>
alert.entity_links?.[entityType === 'po' ? 'purchase_order' : entityType === 'batch' ? 'production_batch' : 'delivery'] === entity.id
);
if (matchingAlert) {
return {
...entity,
alert_reasoning: matchingAlert.reasoning_data,
alert_priority: matchingAlert.priority_level,
alert_timestamp: matchingAlert.timestamp,
};
}
return entity;
});
// Add alerts data as fallback for entities not in services data
alertsData.forEach((alert: any) => {
const entityId = alert.entity_links?.[entityType === 'po' ? 'purchase_order' : entityType === 'batch' ? 'production_batch' : 'delivery'];
if (entityId && !servicesEntityIds.has(entityId)) {
// Create a synthetic entity from alert data
const syntheticEntity = {
id: entityId,
status: alert.event_metadata?.status || 'UNKNOWN',
alert_reasoning: alert.reasoning_data,
alert_priority: alert.priority_level,
alert_timestamp: alert.timestamp,
source: 'alert_fallback',
};
// Add entity-specific fields from alert metadata
if (entityType === 'po') {
(syntheticEntity as any).supplier_id = alert.event_metadata?.supplier_id;
(syntheticEntity as any).po_number = alert.event_metadata?.po_number;
} else if (entityType === 'batch') {
(syntheticEntity as any).batch_number = alert.event_metadata?.batch_number;
(syntheticEntity as any).product_id = alert.event_metadata?.product_id;
} else if (entityType === 'delivery') {
(syntheticEntity as any).expected_delivery_date = alert.event_metadata?.expected_delivery_date;
}
enrichedEntities.push(syntheticEntity);
}
});
return enrichedEntities;
}
/**
* Categorize alerts by type
*/
function categorizeAlerts(alerts: any[], batchIds: Set<string>, deliveryIds: Set<string>): {
equipmentAlerts: any[],
productionAlerts: any[],
otherAlerts: any[]
} {
const equipmentAlerts: any[] = [];
const productionAlerts: any[] = [];
const otherAlerts: any[] = [];
alerts.forEach(alert => {
const eventType = alert.event_type || '';
const batchId = alert.event_metadata?.batch_id || alert.entity_links?.production_batch;
const deliveryId = alert.event_metadata?.delivery_id || alert.entity_links?.delivery;
// Equipment alerts
if (eventType.includes('equipment_') ||
eventType.includes('maintenance') ||
eventType.includes('machine_failure')) {
equipmentAlerts.push(alert);
}
// Production alerts (not equipment-related)
else if (eventType.includes('production.') ||
eventType.includes('batch_') ||
eventType.includes('production_') ||
eventType.includes('delay') ||
(batchId && !batchIds.has(batchId))) {
productionAlerts.push(alert);
}
// Other alerts
else {
otherAlerts.push(alert);
}
});
return { equipmentAlerts, productionAlerts, otherAlerts };
}
// ============================================================
// Main Hook
// ============================================================
export function useControlPanelData(tenantId: string) {
const queryClient = useQueryClient();
const [sseEvents, setSseEvents] = useState<any[]>([]);
// Subscribe to SSE events for control panel
const { events: sseAlerts } = useSSEEvents({
channels: ['*.alerts', '*.notifications', 'recommendations']
});
// Update SSE events state when new events arrive
useEffect(() => {
if (sseAlerts.length > 0) {
setSseEvents(prev => {
// Deduplicate by event ID
const eventIds = new Set(prev.map(e => e.id));
const newEvents = sseAlerts.filter(event => !eventIds.has(event.id));
return [...prev, ...newEvents];
});
}
}, [sseAlerts]);
const query = useQuery<ControlPanelData>({
queryKey: ['control-panel-data', tenantId],
queryFn: async () => {
const today = new Date().toISOString().split('T')[0];
const now = new Date();
const nowUTC = new Date();
// Parallel fetch from all services
const [alertsResponse, pendingPOs, productionResponse, deliveriesResponse, orchestration, suppliers, aiInsightsResponse] = await Promise.all([
alertService.getEvents(tenantId, { status: 'active', limit: 100 }).catch(() => []),
getPendingApprovalPurchaseOrders(tenantId, 100).catch(() => []),
productionService.getBatches(tenantId, { start_date: today, page_size: 100 }).catch(() => ({ batches: [] })),
ProcurementService.getExpectedDeliveries(tenantId, { days_ahead: 1, include_overdue: true }).catch(() => ({ deliveries: [] })),
orchestratorService.getLastOrchestrationRun(tenantId).catch(() => null),
suppliersService.getSuppliers(tenantId).catch(() => []),
aiInsightsService.getInsights(tenantId, {
status: 'new',
priority: 'high',
limit: 5
}).catch(() => ({ items: [], total: 0, limit: 5, offset: 0, has_more: false })),
]);
// Normalize responses
const alerts = Array.isArray(alertsResponse) ? alertsResponse : (alertsResponse?.items || []);
const productionBatches = productionResponse?.batches || [];
const deliveries = deliveriesResponse?.deliveries || [];
const aiInsights = aiInsightsResponse?.items || [];
// Create supplier map
const supplierMap = new Map<string, string>();
(suppliers || []).forEach((supplier: any) => {
supplierMap.set(supplier.id, supplier.name || supplier.supplier_name);
});
// Merge SSE events with API data (deduplicate by ID, prioritizing SSE events as they're newer)
let allAlerts: any[];
if (sseEvents.length > 0) {
const sseEventIds = new Set(sseEvents.map(e => e.id));
// Filter out API alerts that also exist in SSE (SSE has newer data)
const uniqueApiAlerts = alerts.filter((alert: any) => !sseEventIds.has(alert.id));
allAlerts = [...uniqueApiAlerts, ...sseEvents];
} else {
allAlerts = [...alerts];
}
// Apply data priority rules for POs
const enrichedPendingPOs = mergeDataWithPriority(pendingPOs, allAlerts, 'po');
// Apply data priority rules for batches
const enrichedProductionBatches = mergeDataWithPriority(productionBatches, allAlerts, 'batch');
// Apply data priority rules for deliveries
const enrichedDeliveries = mergeDataWithPriority(deliveries, allAlerts, 'delivery');
// Filter and categorize data
const isPending = (status: string) =>
status === 'PENDING' || status === 'sent_to_supplier' || status === 'confirmed';
const overdueDeliveries = enrichedDeliveries.filter((d: any) => {
if (!isPending(d.status)) return false;
const expectedDate = parseISO(d.expected_delivery_date);
return expectedDate < nowUTC;
}).map((d: any) => ({
...d,
hoursOverdue: Math.ceil((nowUTC.getTime() - parseISO(d.expected_delivery_date).getTime()) / (1000 * 60 * 60)),
}));
const pendingDeliveriesFiltered = enrichedDeliveries.filter((d: any) => {
if (!isPending(d.status)) return false;
const expectedDate = parseISO(d.expected_delivery_date);
return expectedDate >= nowUTC;
}).map((d: any) => ({
...d,
hoursUntil: Math.ceil((parseISO(d.expected_delivery_date).getTime() - nowUTC.getTime()) / (1000 * 60 * 60)),
}));
// Filter production batches
const lateToStartBatches = enrichedProductionBatches.filter((b: any) => {
const status = b.status?.toUpperCase();
if (status !== 'PENDING' && status !== 'SCHEDULED') return false;
const plannedStart = b.planned_start_time;
if (!plannedStart) return false;
return parseISO(plannedStart) < nowUTC;
}).map((b: any) => ({
...b,
hoursLate: Math.ceil((nowUTC.getTime() - parseISO(b.planned_start_time).getTime()) / (1000 * 60 * 60)),
}));
const runningBatches = enrichedProductionBatches.filter((b: any) =>
b.status?.toUpperCase() === 'IN_PROGRESS'
);
const pendingBatchesFiltered = enrichedProductionBatches.filter((b: any) => {
const status = b.status?.toUpperCase();
if (status !== 'PENDING' && status !== 'SCHEDULED') return false;
const plannedStart = b.planned_start_time;
if (!plannedStart) return true;
return parseISO(plannedStart) >= nowUTC;
});
// Create sets for deduplication
const lateBatchIds = new Set(lateToStartBatches.map((b: any) => b.id));
const runningBatchIds = new Set(runningBatches.map((b: any) => b.id));
const deliveryIds = new Set([...overdueDeliveries, ...pendingDeliveriesFiltered].map((d: any) => d.id));
// Create array of all batch IDs for categorization
const allBatchIds = new Set([
...Array.from(lateBatchIds),
...Array.from(runningBatchIds),
...pendingBatchesFiltered.map((b: any) => b.id)
]);
// Categorize alerts and filter out duplicates for batches already shown
const { equipmentAlerts, productionAlerts, otherAlerts } = categorizeAlerts(
allAlerts,
allBatchIds,
deliveryIds
);
// Additional deduplication: filter out equipment alerts for batches already shown in UI
const deduplicatedEquipmentAlerts = equipmentAlerts.filter(alert => {
const batchId = alert.event_metadata?.batch_id || alert.entity_links?.production_batch;
if (batchId && allBatchIds.has(batchId)) {
return false; // Filter out if batch is already shown
}
return true;
});
// Compute derived data
const preventedIssues = allAlerts.filter((a: any) => a.type_class === 'prevented_issue');
const actionNeededAlerts = allAlerts.filter((a: any) =>
a.type_class === 'action_needed' &&
!a.hidden_from_ui &&
a.status === 'active'
);
// Debug: Log alert counts by type_class
console.log('📊 [useControlPanelData] Alert analysis:', {
totalAlerts: allAlerts.length,
fromAPI: alerts.length,
fromSSE: sseEvents.length,
preventedIssuesCount: preventedIssues.length,
actionNeededCount: actionNeededAlerts.length,
typeClassBreakdown: allAlerts.reduce((acc: Record<string, number>, a: any) => {
const typeClass = a.type_class || 'unknown';
acc[typeClass] = (acc[typeClass] || 0) + 1;
return acc;
}, {}),
apiAlertsSample: alerts.slice(0, 3).map((a: any) => ({
id: a.id,
event_type: a.event_type,
type_class: a.type_class,
status: a.status,
})),
sseEventsSample: sseEvents.slice(0, 3).map((a: any) => ({
id: a.id,
event_type: a.event_type,
type_class: a.type_class,
status: a.status,
})),
});
// Calculate total issues requiring action:
// 1. Action needed alerts
// 2. Pending PO approvals (each PO requires approval action)
// 3. Late to start batches (each requires start action)
const issuesRequiringAction = actionNeededAlerts.length +
enrichedPendingPOs.length +
lateToStartBatches.length;
// Build orchestration summary
let orchestrationSummary: OrchestrationSummary | null = null;
if (orchestration && orchestration.timestamp) {
orchestrationSummary = {
runTimestamp: orchestration.timestamp,
runNumber: orchestration.runNumber ?? undefined,
status: 'completed',
purchaseOrdersCreated: enrichedPendingPOs.length,
productionBatchesCreated: enrichedProductionBatches.length,
userActionsRequired: actionNeededAlerts.length,
aiHandlingRate: preventedIssues.length > 0
? Math.round((preventedIssues.length / (preventedIssues.length + actionNeededAlerts.length)) * 100)
: undefined,
estimatedSavingsEur: preventedIssues.length * 50,
};
}
return {
// Raw data
alerts: allAlerts,
pendingPOs: enrichedPendingPOs,
productionBatches: enrichedProductionBatches,
deliveries: enrichedDeliveries,
orchestrationSummary,
aiInsights,
// Computed
preventedIssues,
issuesRequiringAction,
issuesPreventedByAI: preventedIssues.length,
// Filtered for blocks
overdueDeliveries,
pendingDeliveries: pendingDeliveriesFiltered,
lateToStartBatches,
runningBatches,
pendingBatches: pendingBatchesFiltered,
// Categorized alerts (deduplicated to prevent showing alerts for batches already in UI)
equipmentAlerts: deduplicatedEquipmentAlerts,
productionAlerts,
otherAlerts,
};
},
enabled: !!tenantId,
staleTime: 20000, // 20 seconds
refetchOnMount: true,
refetchOnWindowFocus: false,
retry: 2,
});
// Ref for debouncing SSE-triggered invalidations
const invalidationTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const lastEventCountRef = useRef<number>(0);
// Track when the initial data was successfully fetched to avoid immediate SSE refetches
const initialLoadTimestampRef = useRef<number | null>(null);
// Update initial load timestamp when query succeeds
useEffect(() => {
if (query.isSuccess && !initialLoadTimestampRef.current) {
initialLoadTimestampRef.current = Date.now();
}
}, [query.isSuccess]);
// SSE integration - invalidate query on relevant events (debounced)
useEffect(() => {
// Skip if no new events since last check
if (sseAlerts.length === 0 || !tenantId || sseAlerts.length === lastEventCountRef.current) {
return;
}
// OPTIMIZATION: Skip SSE-triggered invalidation during grace period after initial load
// This prevents duplicate API calls when SSE events arrive during/right after the initial fetch
if (initialLoadTimestampRef.current) {
const timeSinceInitialLoad = Date.now() - initialLoadTimestampRef.current;
if (timeSinceInitialLoad < SSE_INITIAL_LOAD_GRACE_PERIOD_MS) {
// Update the event count ref so we don't process these events later
lastEventCountRef.current = sseAlerts.length;
return;
}
}
const relevantEvents = sseAlerts.filter(event =>
event.event_type?.includes('production.') ||
event.event_type?.includes('batch_') ||
event.event_type?.includes('delivery') ||
event.event_type?.includes('purchase_order') ||
event.event_type?.includes('equipment_') ||
event.event_type?.includes('insight') ||
event.event_type?.includes('recommendation') ||
event.event_type?.includes('ai_') || // Match ai_yield_prediction, ai_*, etc.
event.event_class === 'recommendation'
);
if (relevantEvents.length > 0) {
// Clear existing timeout to debounce rapid events
if (invalidationTimeoutRef.current) {
clearTimeout(invalidationTimeoutRef.current);
}
// Debounce the invalidation to prevent multiple rapid refetches
invalidationTimeoutRef.current = setTimeout(() => {
lastEventCountRef.current = sseAlerts.length;
queryClient.invalidateQueries({
queryKey: ['control-panel-data', tenantId],
refetchType: 'active',
});
}, SSE_INVALIDATION_DEBOUNCE_MS);
}
// Cleanup timeout on unmount or dependency change
return () => {
if (invalidationTimeoutRef.current) {
clearTimeout(invalidationTimeoutRef.current);
}
};
}, [sseAlerts, tenantId, queryClient]);
return query;
}
// ============================================================
// Real-time SSE Hook for Control Panel
// ============================================================
export function useControlPanelRealtimeSync(tenantId: string) {
const queryClient = useQueryClient();
// Subscribe to SSE events
const { events: sseEvents } = useSSEEvents({
channels: ['*.alerts', '*.notifications', 'recommendations']
});
// Invalidate control panel data on relevant events
useEffect(() => {
if (sseEvents.length === 0 || !tenantId) return;
const latest = sseEvents[0];
const relevantEventTypes = [
'batch_completed', 'batch_started', 'batch_state_changed',
'delivery_received', 'delivery_overdue', 'delivery_arriving_soon',
'stock_receipt_incomplete', 'orchestration_run_completed',
'production_delay', 'batch_start_delayed', 'equipment_maintenance'
];
if (relevantEventTypes.includes(latest.event_type)) {
queryClient.invalidateQueries({
queryKey: ['control-panel-data', tenantId],
refetchType: 'active',
});
}
}, [sseEvents, tenantId, queryClient]);
}

View File

@@ -0,0 +1,452 @@
/**
* Enterprise Dashboard Hooks
*
* Direct service calls for enterprise network metrics.
* Fetch data from individual microservices and perform client-side aggregation.
*/
import { useQuery, UseQueryResult } from '@tanstack/react-query';
import { tenantService } from '../services/tenant';
import { salesService } from '../services/sales';
import { inventoryService } from '../services/inventory';
import { productionService } from '../services/production';
import { distributionService } from '../services/distribution';
import { forecastingService } from '../services/forecasting';
import { getPendingApprovalPurchaseOrders } from '../services/purchase_orders';
import { ProcurementService } from '../services/procurement-service';
// ================================================================
// TYPE DEFINITIONS
// ================================================================
export interface ChildTenant {
id: string;
name: string;
business_name: string;
account_type: string;
parent_tenant_id: string | null;
is_active: boolean;
}
export interface SalesSummary {
total_revenue: number;
total_quantity: number;
total_orders: number;
average_order_value: number;
top_products: Array<{
product_name: string;
quantity_sold: number;
revenue: number;
}>;
}
export interface InventorySummary {
tenant_id: string;
total_value: number;
out_of_stock_count: number;
low_stock_count: number;
adequate_stock_count: number;
total_ingredients: number;
}
export interface ProductionSummary {
tenant_id: string;
total_batches: number;
pending_batches: number;
in_progress_batches: number;
completed_batches: number;
total_planned_quantity: number;
total_actual_quantity: number;
efficiency_rate: number;
}
export interface NetworkSummary {
parent_tenant_id: string;
child_tenant_count: number;
network_sales_30d: number;
production_volume_30d: number;
pending_internal_transfers_count: number;
active_shipments_count: number;
}
export interface ChildPerformance {
rank: number;
tenant_id: string;
outlet_name: string;
metric_value: number;
}
export interface PerformanceRankings {
parent_tenant_id: string;
metric: string;
period_days: number;
rankings: ChildPerformance[];
total_children: number;
}
export interface DistributionOverview {
date: string;
route_sequences: any[]; // Define more specific type as needed
status_counts: Record<string, number>;
}
export interface ForecastSummary {
days_forecast: number;
aggregated_forecasts: Record<string, any>; // Define more specific type as needed
last_updated: string;
}
// ================================================================
// CHILD TENANTS
// ================================================================
/**
* Get list of child tenants for a parent
*/
export const useChildTenants = (
parentTenantId: string,
options?: { enabled?: boolean }
): UseQueryResult<ChildTenant[]> => {
return useQuery({
queryKey: ['tenants', 'children', parentTenantId],
queryFn: async () => {
const response = await tenantService.getChildTenants(parentTenantId);
// Map TenantResponse to ChildTenant
return response.map(tenant => ({
id: tenant.id,
name: tenant.name,
business_name: tenant.name, // TenantResponse uses 'name' as business name
account_type: tenant.business_type, // TenantResponse uses 'business_type'
parent_tenant_id: parentTenantId, // Set from the parent
is_active: tenant.is_active,
}));
},
staleTime: 60000, // 1 min cache (doesn't change often)
enabled: options?.enabled ?? true,
});
};
// ================================================================
// NETWORK SUMMARY (Client-Side Aggregation)
// ================================================================
/**
* Get network summary by aggregating data from multiple services client-side
*/
export const useNetworkSummary = (
parentTenantId: string,
options?: { enabled?: boolean }
): UseQueryResult<NetworkSummary> => {
const { data: childTenants } = useChildTenants(parentTenantId, options);
return useQuery({
queryKey: ['enterprise', 'network-summary', parentTenantId],
queryFn: async () => {
const childTenantIds = (childTenants || []).map((c) => c.id);
const allTenantIds = [parentTenantId, ...childTenantIds];
// Calculate date range for 30-day sales
const endDate = new Date();
const startDate = new Date();
startDate.setDate(startDate.getDate() - 30);
// Fetch all data in parallel using service abstractions
const [salesBatch, productionData, pendingPOs, shipmentsData] = await Promise.all([
// Sales for all tenants (batch)
salesService.getBatchSalesSummary(
allTenantIds,
startDate.toISOString().split('T')[0],
endDate.toISOString().split('T')[0]
),
// Production volume for parent
productionService.getDashboardSummary(parentTenantId),
// Pending internal transfers (purchase orders marked as internal)
getPendingApprovalPurchaseOrders(parentTenantId, 100),
// Active shipments
distributionService.getShipments(parentTenantId),
]);
// Ensure data are arrays before filtering
const shipmentsList = Array.isArray(shipmentsData) ? shipmentsData : [];
const posList = Array.isArray(pendingPOs) ? pendingPOs : [];
// Aggregate network sales
const networkSales = Object.values(salesBatch).reduce(
(sum: number, summary: any) => sum + (summary?.total_revenue || 0),
0
);
// Count active shipments
const activeStatuses = ['pending', 'in_transit', 'packed'];
const activeShipmentsCount = shipmentsList.filter((s: any) =>
activeStatuses.includes(s.status)
).length;
// Count pending transfers (assuming POs with internal flag)
const pendingTransfers = posList.filter((po: any) =>
po.reference_number?.includes('INTERNAL') || po.notes?.toLowerCase().includes('internal')
).length;
return {
parent_tenant_id: parentTenantId,
child_tenant_count: childTenantIds.length,
network_sales_30d: networkSales,
production_volume_30d: (productionData as any)?.total_value || 0,
pending_internal_transfers_count: pendingTransfers,
active_shipments_count: activeShipmentsCount,
};
},
staleTime: 30000, // 30s cache
enabled: (options?.enabled ?? true) && !!childTenants,
});
};
// ================================================================
// CHILDREN PERFORMANCE (Client-Side Aggregation)
// ================================================================
/**
* Get performance rankings for child tenants
*/
export const useChildrenPerformance = (
parentTenantId: string,
metric: 'sales' | 'inventory_value' | 'production',
periodDays: number = 30,
options?: { enabled?: boolean }
): UseQueryResult<PerformanceRankings> => {
const { data: childTenants } = useChildTenants(parentTenantId, options);
return useQuery({
queryKey: ['enterprise', 'children-performance', parentTenantId, metric, periodDays],
queryFn: async () => {
if (!childTenants || childTenants.length === 0) {
return {
parent_tenant_id: parentTenantId,
metric,
period_days: periodDays,
rankings: [],
total_children: 0,
};
}
const childTenantIds = childTenants.map((c) => c.id);
let batchData: Record<string, any> = {};
if (metric === 'sales') {
// Fetch sales batch
const endDate = new Date();
const startDate = new Date();
startDate.setDate(startDate.getDate() - periodDays);
batchData = await salesService.getBatchSalesSummary(
childTenantIds,
startDate.toISOString().split('T')[0],
endDate.toISOString().split('T')[0]
);
} else if (metric === 'inventory_value') {
// Fetch inventory batch
batchData = await inventoryService.getBatchInventorySummary(childTenantIds);
} else if (metric === 'production') {
// Fetch production batch
batchData = await productionService.getBatchProductionSummary(childTenantIds);
}
// Build performance data
const performanceData = childTenants.map((child) => {
const summary = batchData[child.id] || {};
let metricValue = 0;
if (metric === 'sales') {
metricValue = summary.total_revenue || 0;
} else if (metric === 'inventory_value') {
metricValue = summary.total_value || 0;
} else if (metric === 'production') {
metricValue = summary.completed_batches || 0;
}
return {
tenant_id: child.id,
outlet_name: child.name,
metric_value: metricValue,
};
});
// Sort by metric value descending
performanceData.sort((a, b) => b.metric_value - a.metric_value);
// Add rankings
const rankings = performanceData.map((data, index) => ({
rank: index + 1,
...data,
}));
return {
parent_tenant_id: parentTenantId,
metric,
period_days: periodDays,
rankings,
total_children: childTenants.length,
};
},
staleTime: 30000, // 30s cache
enabled: (options?.enabled ?? true) && !!childTenants,
});
};
// ================================================================
// DISTRIBUTION OVERVIEW
// ================================================================
/**
* Get distribution overview for enterprise
*/
export const useDistributionOverview = (
parentTenantId: string,
date: string,
options?: { enabled?: boolean; refetchInterval?: number }
): UseQueryResult<DistributionOverview> => {
return useQuery({
queryKey: ['enterprise', 'distribution-overview', parentTenantId, date],
queryFn: async () => {
// Get distribution data directly from distribution service
const routes = await distributionService.getRouteSequences(parentTenantId, date);
const shipments = await distributionService.getShipments(parentTenantId, date);
// Count shipment statuses
const statusCounts: Record<string, number> = {};
const shipmentsList = Array.isArray(shipments) ? shipments : [];
for (const shipment of shipmentsList) {
statusCounts[shipment.status] = (statusCounts[shipment.status] || 0) + 1;
}
return {
date,
route_sequences: Array.isArray(routes) ? routes : [],
status_counts: statusCounts,
};
},
staleTime: 30000, // 30s cache
enabled: options?.enabled ?? true,
refetchInterval: options?.refetchInterval,
});
};
// ================================================================
// FORECAST SUMMARY
// ================================================================
/**
* Get aggregated forecast summary for the enterprise network
*/
export const useForecastSummary = (
parentTenantId: string,
daysAhead: number = 7,
options?: { enabled?: boolean }
): UseQueryResult<ForecastSummary> => {
return useQuery({
queryKey: ['enterprise', 'forecast-summary', parentTenantId, daysAhead],
queryFn: async () => {
// Get forecast data directly from forecasting service
// Using existing batch forecasting functionality from the service
const endDate = new Date();
const startDate = new Date();
startDate.setDate(startDate.getDate() + 1); // Tomorrow
endDate.setDate(endDate.getDate() + daysAhead); // End of forecast period
// Get forecast data directly from forecasting service
// Get forecasts for the next N days
const forecastsResponse = await forecastingService.getTenantForecasts(parentTenantId, {
start_date: startDate.toISOString().split('T')[0],
end_date: endDate.toISOString().split('T')[0],
});
// Extract forecast data from response
const forecastItems = forecastsResponse?.items || [];
const aggregated_forecasts: Record<string, any> = {};
// Group forecasts by date
for (const forecast of forecastItems) {
const date = forecast.forecast_date || forecast.date;
if (date) {
aggregated_forecasts[date] = forecast;
}
}
return {
days_forecast: daysAhead,
aggregated_forecasts,
last_updated: new Date().toISOString(),
};
},
staleTime: 300000, // 5 min cache (forecasts don't change very frequently)
enabled: options?.enabled ?? true,
});
};
// ================================================================
// INDIVIDUAL CHILD METRICS (for detailed views)
// ================================================================
/**
* Get sales for a specific child tenant
*/
export const useChildSales = (
tenantId: string,
periodDays: number = 30,
options?: { enabled?: boolean }
): UseQueryResult<SalesSummary> => {
return useQuery({
queryKey: ['sales', 'summary', tenantId, periodDays],
queryFn: async () => {
const endDate = new Date();
const startDate = new Date();
startDate.setDate(startDate.getDate() - periodDays);
return await salesService.getSalesAnalytics(
tenantId,
startDate.toISOString().split('T')[0],
endDate.toISOString().split('T')[0]
) as any;
},
staleTime: 30000,
enabled: options?.enabled ?? true,
});
};
/**
* Get inventory for a specific child tenant
*/
export const useChildInventory = (
tenantId: string,
options?: { enabled?: boolean }
): UseQueryResult<InventorySummary> => {
return useQuery({
queryKey: ['inventory', 'summary', tenantId],
queryFn: async () => {
return await inventoryService.getDashboardSummary(tenantId) as any;
},
staleTime: 30000,
enabled: options?.enabled ?? true,
});
};
/**
* Get production for a specific child tenant
*/
export const useChildProduction = (
tenantId: string,
options?: { enabled?: boolean }
): UseQueryResult<ProductionSummary> => {
return useQuery({
queryKey: ['production', 'summary', tenantId],
queryFn: async () => {
return await productionService.getDashboardSummary(tenantId) as any;
},
staleTime: 30000,
enabled: options?.enabled ?? true,
});
};

View File

@@ -0,0 +1,97 @@
/**
* Direct Inventory Service Hook
*
* Phase 1 optimization: Call inventory service directly instead of through orchestrator.
* Eliminates duplicate fetches and reduces orchestrator load.
*/
import { useQuery, UseQueryResult } from '@tanstack/react-query';
import { getTenantEndpoint } from '../../config/services';
import { apiClient } from '../client';
export interface StockStatus {
category: string;
in_stock: number;
low_stock: number;
out_of_stock: number;
total: number;
}
export interface InventoryOverview {
out_of_stock_count: number;
low_stock_count: number;
adequate_stock_count: number;
total_ingredients: number;
total_value?: number;
tenant_id: string;
timestamp: string;
}
export interface SustainabilityWidget {
waste_reduction_percentage: number;
local_sourcing_percentage: number;
seasonal_usage_percentage: number;
carbon_footprint_score?: number;
}
/**
* Fetch inventory overview directly from inventory service
*/
export const useInventoryOverview = (
tenantId: string,
options?: {
enabled?: boolean;
refetchInterval?: number;
}
): UseQueryResult<InventoryOverview> => {
return useQuery({
queryKey: ['inventory', 'overview', tenantId],
queryFn: async () => {
const url = getTenantEndpoint('inventory', tenantId, 'inventory/dashboard/overview');
return await apiClient.get(url);
},
staleTime: 30000, // 30s cache
refetchInterval: options?.refetchInterval,
enabled: options?.enabled ?? true,
});
};
/**
* Fetch stock status by category directly from inventory service
*/
export const useStockStatusByCategory = (
tenantId: string,
options?: {
enabled?: boolean;
}
): UseQueryResult<StockStatus[]> => {
return useQuery({
queryKey: ['inventory', 'stock-status', tenantId],
queryFn: async () => {
const url = getTenantEndpoint('inventory', tenantId, 'inventory/dashboard/stock-status');
return await apiClient.get(url);
},
staleTime: 30000,
enabled: options?.enabled ?? true,
});
};
/**
* Fetch sustainability widget data directly from inventory service
*/
export const useSustainabilityWidget = (
tenantId: string,
options?: {
enabled?: boolean;
}
): UseQueryResult<SustainabilityWidget> => {
return useQuery({
queryKey: ['inventory', 'sustainability', 'widget', tenantId],
queryFn: async () => {
const url = getTenantEndpoint('inventory', tenantId, 'sustainability/widget');
return await apiClient.get(url);
},
staleTime: 60000, // 60s cache (changes less frequently)
enabled: options?.enabled ?? true,
});
};

View File

@@ -0,0 +1,79 @@
/**
* Hook for premises (child tenants) management
*/
import { useQuery, UseQueryResult } from '@tanstack/react-query';
import { tenantService } from '../services/tenant';
import type { TenantResponse } from '../types/tenant';
export interface PremisesFilters {
search?: string;
status?: 'active' | 'inactive' | '';
}
export interface PremisesStats {
total: number;
active: number;
inactive: number;
}
/**
* Get all child tenants (premises) for a parent tenant
*/
export const usePremises = (
parentTenantId: string,
filters?: PremisesFilters,
options?: { enabled?: boolean }
): UseQueryResult<TenantResponse[]> => {
return useQuery({
queryKey: ['premises', parentTenantId, filters],
queryFn: async () => {
const response = await tenantService.getChildTenants(parentTenantId);
let filtered = response;
// Apply search filter
if (filters?.search) {
const searchLower = filters.search.toLowerCase();
filtered = filtered.filter(tenant =>
tenant.name.toLowerCase().includes(searchLower) ||
tenant.city?.toLowerCase().includes(searchLower)
);
}
// Apply status filter
if (filters?.status === 'active') {
filtered = filtered.filter(tenant => tenant.is_active);
} else if (filters?.status === 'inactive') {
filtered = filtered.filter(tenant => !tenant.is_active);
}
return filtered;
},
staleTime: 60000, // 1 min cache
enabled: (options?.enabled ?? true) && !!parentTenantId,
});
};
/**
* Get premises statistics
*/
export const usePremisesStats = (
parentTenantId: string,
options?: { enabled?: boolean }
): UseQueryResult<PremisesStats> => {
return useQuery({
queryKey: ['premises', 'stats', parentTenantId],
queryFn: async () => {
const response = await tenantService.getChildTenants(parentTenantId);
return {
total: response.length,
active: response.filter(t => t.is_active).length,
inactive: response.filter(t => !t.is_active).length,
};
},
staleTime: 60000,
enabled: (options?.enabled ?? true) && !!parentTenantId,
});
};

View File

@@ -0,0 +1,81 @@
/**
* Direct Production Service Hook
*
* Phase 1 optimization: Call production service directly instead of through orchestrator.
* Eliminates one network hop and reduces orchestrator load.
*/
import { useQuery, UseQueryResult } from '@tanstack/react-query';
import { getTenantEndpoint } from '../../config/services';
import { apiClient } from '../client';
export interface ProductionBatch {
id: string;
product_id: string;
product_name: string;
planned_quantity: number;
actual_quantity?: number;
status: 'PENDING' | 'IN_PROGRESS' | 'COMPLETED' | 'ON_HOLD' | 'CANCELLED';
planned_start_time: string;
planned_end_time: string;
actual_start_time?: string;
actual_end_time?: string;
priority: 'LOW' | 'NORMAL' | 'HIGH' | 'URGENT';
notes?: string;
}
export interface ProductionBatchesResponse {
batches: ProductionBatch[];
total_count: number;
date: string;
}
/**
* Fetch today's production batches directly from production service
*/
export const useProductionBatches = (
tenantId: string,
options?: {
enabled?: boolean;
refetchInterval?: number;
}
): UseQueryResult<ProductionBatchesResponse> => {
return useQuery({
queryKey: ['production', 'batches', 'today', tenantId],
queryFn: async () => {
const url = getTenantEndpoint('production', tenantId, 'production/batches/today');
return await apiClient.get(url);
},
staleTime: 30000, // 30s cache
refetchInterval: options?.refetchInterval,
enabled: options?.enabled ?? true,
});
};
/**
* Fetch production batches by status directly from production service
*/
export const useProductionBatchesByStatus = (
tenantId: string,
status: string,
options?: {
enabled?: boolean;
limit?: number;
}
): UseQueryResult<ProductionBatchesResponse> => {
const limit = options?.limit ?? 100;
return useQuery({
queryKey: ['production', 'batches', 'status', status, tenantId, limit],
queryFn: async () => {
const url = getTenantEndpoint(
'production',
tenantId,
`production/batches?status=${status}&limit=${limit}`
);
return await apiClient.get(url);
},
staleTime: 30000,
enabled: options?.enabled ?? true,
});
};

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,154 @@
import { useState, useEffect, useCallback } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { Alert, AlertQueryParams } from '../types/events';
import {
useEvents,
useEventsSummary,
useAcknowledgeAlert,
useResolveAlert,
useCancelAutoAction,
useBulkAcknowledgeAlerts,
useBulkResolveAlerts
} from './useAlerts';
import { useSSEEvents } from '../../hooks/useSSE';
import { AlertFilterOptions, applyAlertFilters } from '../../utils/alertManagement';
interface UseUnifiedAlertsConfig {
refetchInterval?: number;
enableSSE?: boolean;
sseChannels?: string[];
}
interface UseUnifiedAlertsReturn {
alerts: Alert[];
filteredAlerts: Alert[];
stats: any;
filters: AlertFilterOptions;
setFilters: (filters: AlertFilterOptions) => void;
search: string;
setSearch: (search: string) => void;
isLoading: boolean;
isRefetching: boolean;
error: Error | null;
refetch: () => void;
acknowledgeAlert: (alertId: string) => Promise<void>;
resolveAlert: (alertId: string) => Promise<void>;
cancelAutoAction: (alertId: string) => Promise<void>;
acknowledgeAlertsByMetadata: (alertType: string, metadata: any) => Promise<void>;
resolveAlertsByMetadata: (alertType: string, metadata: any) => Promise<void>;
isSSEConnected: boolean;
sseError: Error | null;
}
export function useUnifiedAlerts(
tenantId: string,
initialFilters: AlertFilterOptions = {},
config: UseUnifiedAlertsConfig = {}
): UseUnifiedAlertsReturn {
const [filters, setFilters] = useState<AlertFilterOptions>(initialFilters);
const [search, setSearch] = useState('');
// Fetch alerts and summary
const {
data: alertsData,
isLoading,
isRefetching,
error,
refetch
} = useEvents(tenantId, filters as AlertQueryParams);
const { data: summaryData } = useEventsSummary(tenantId);
// Alert mutations
const acknowledgeMutation = useAcknowledgeAlert({ tenantId });
const resolveMutation = useResolveAlert({ tenantId });
const cancelAutoActionMutation = useCancelAutoAction({ tenantId });
const bulkAcknowledgeMutation = useBulkAcknowledgeAlerts({ tenantId });
const bulkResolveMutation = useBulkResolveAlerts({ tenantId });
// SSE connection for real-time updates
const [isSSEConnected, setSSEConnected] = useState(false);
const [sseError, setSSEError] = useState<Error | null>(null);
// Enable SSE if configured
if (config.enableSSE) {
useSSEEvents({
channels: config.sseChannels || [`*.alerts`, `*.notifications`],
});
}
// Process alerts data
const allAlerts: Alert[] = alertsData?.items || [];
// Apply filters and search
const filteredAlerts = applyAlertFilters(allAlerts, filters, search);
// Mutation functions
const handleAcknowledgeAlert = async (alertId: string) => {
await acknowledgeMutation.mutateAsync(alertId);
refetch();
};
const handleResolveAlert = async (alertId: string) => {
await resolveMutation.mutateAsync(alertId);
refetch();
};
const handleCancelAutoAction = async (alertId: string) => {
await cancelAutoActionMutation.mutateAsync(alertId);
};
const handleAcknowledgeAlertsByMetadata = async (alertType: string, metadata: any) => {
await bulkAcknowledgeMutation.mutateAsync({
alertType,
metadataFilter: metadata
});
refetch();
};
const handleResolveAlertsByMetadata = async (alertType: string, metadata: any) => {
await bulkResolveMutation.mutateAsync({
alertType,
metadataFilter: metadata
});
refetch();
};
return {
alerts: allAlerts,
filteredAlerts,
stats: summaryData,
filters,
setFilters,
search,
setSearch,
isLoading,
isRefetching,
error: error || null,
refetch,
acknowledgeAlert: handleAcknowledgeAlert,
resolveAlert: handleResolveAlert,
cancelAutoAction: handleCancelAutoAction,
acknowledgeAlertsByMetadata: handleAcknowledgeAlertsByMetadata,
resolveAlertsByMetadata: handleResolveAlertsByMetadata,
isSSEConnected,
sseError,
};
}
// Additional hooks that may be used with unified alerts
export function useSingleAlert(tenantId: string, alertId: string) {
return useEvent(tenantId, alertId);
}
export function useAlertStats(tenantId: string) {
return useEventsSummary(tenantId);
}
export function useRealTimeAlerts(tenantId: string, channels?: string[]) {
const { notifications } = useSSEEvents({
channels: channels || [`*.alerts`, `*.notifications`, `*.recommendations`],
});
return { realTimeAlerts: notifications };
}

View File

@@ -0,0 +1,126 @@
/**
* User React Query hooks
*/
import { useMutation, useQuery, useQueryClient, UseQueryOptions, UseMutationOptions } from '@tanstack/react-query';
import { userService } from '../services/user';
import { UserResponse, UserUpdate } from '../types/auth';
import { AdminDeleteRequest, AdminDeleteResponse } from '../types/user';
import { ApiError } from '../client';
// Query Keys
export const userKeys = {
all: ['user'] as const,
current: () => [...userKeys.all, 'current'] as const,
detail: (id: string) => [...userKeys.all, 'detail', id] as const,
activity: (id: string) => [...userKeys.all, 'activity', id] as const,
admin: {
all: () => [...userKeys.all, 'admin'] as const,
list: () => [...userKeys.admin.all(), 'list'] as const,
detail: (id: string) => [...userKeys.admin.all(), 'detail', id] as const,
},
} as const;
// Queries
export const useCurrentUser = (
options?: Omit<UseQueryOptions<UserResponse, ApiError>, 'queryKey' | 'queryFn'>
) => {
return useQuery<UserResponse, ApiError>({
queryKey: userKeys.current(),
queryFn: () => userService.getCurrentUser(),
staleTime: 5 * 60 * 1000, // 5 minutes
...options,
});
};
export const useUserActivity = (
userId: string,
options?: Omit<UseQueryOptions<any, ApiError>, 'queryKey' | 'queryFn'>
) => {
return useQuery<any, ApiError>({
queryKey: userKeys.activity(userId),
queryFn: () => userService.getUserActivity(userId),
enabled: !!userId,
staleTime: 1 * 60 * 1000, // 1 minute for activity data
...options,
});
};
export const useAllUsers = (
options?: Omit<UseQueryOptions<UserResponse[], ApiError>, 'queryKey' | 'queryFn'>
) => {
return useQuery<UserResponse[], ApiError>({
queryKey: userKeys.admin.list(),
queryFn: () => userService.getAllUsers(),
staleTime: 2 * 60 * 1000, // 2 minutes
...options,
});
};
export const useUserById = (
userId: string,
options?: Omit<UseQueryOptions<UserResponse, ApiError>, 'queryKey' | 'queryFn'>
) => {
return useQuery<UserResponse, ApiError>({
queryKey: userKeys.admin.detail(userId),
queryFn: () => userService.getUserById(userId),
enabled: !!userId,
staleTime: 5 * 60 * 1000, // 5 minutes
...options,
});
};
// Mutations
export const useUpdateUser = (
options?: UseMutationOptions<UserResponse, ApiError, { userId: string; updateData: UserUpdate }>
) => {
const queryClient = useQueryClient();
return useMutation<UserResponse, ApiError, { userId: string; updateData: UserUpdate }>({
mutationFn: ({ userId, updateData }) => userService.updateUser(userId, updateData),
onSuccess: (data, { userId }) => {
// Update user cache
queryClient.setQueryData(userKeys.detail(userId), data);
queryClient.setQueryData(userKeys.current(), data);
queryClient.setQueryData(userKeys.admin.detail(userId), data);
// Invalidate user lists
queryClient.invalidateQueries({ queryKey: userKeys.admin.list() });
},
...options,
});
};
export const useDeleteUser = (
options?: UseMutationOptions<{ message: string }, ApiError, string>
) => {
const queryClient = useQueryClient();
return useMutation<{ message: string }, ApiError, string>({
mutationFn: (userId: string) => userService.deleteUser(userId),
onSuccess: (data, userId) => {
// Remove from cache
queryClient.removeQueries({ queryKey: userKeys.detail(userId) });
queryClient.removeQueries({ queryKey: userKeys.admin.detail(userId) });
// Invalidate user lists
queryClient.invalidateQueries({ queryKey: userKeys.admin.list() });
},
...options,
});
};
export const useAdminDeleteUser = (
options?: UseMutationOptions<AdminDeleteResponse, ApiError, AdminDeleteRequest>
) => {
const queryClient = useQueryClient();
return useMutation<AdminDeleteResponse, ApiError, AdminDeleteRequest>({
mutationFn: (deleteRequest: AdminDeleteRequest) => userService.adminDeleteUser(deleteRequest),
onSuccess: (data, request) => {
// Remove from cache
queryClient.removeQueries({ queryKey: userKeys.detail(request.user_id) });
queryClient.removeQueries({ queryKey: userKeys.admin.detail(request.user_id) });
// Invalidate user lists
queryClient.invalidateQueries({ queryKey: userKeys.admin.list() });
},
...options,
});
};

785
frontend/src/api/index.ts Normal file
View File

@@ -0,0 +1,785 @@
/**
* Main API exports for clean imports
* Export all services, types, and hooks
*/
// Client
export { apiClient } from './client';
export type { ApiError } from './client';
// Services
export { authService } from './services/auth';
export { userService } from './services/user';
export { onboardingService } from './services/onboarding';
export { tenantService } from './services/tenant';
export { subscriptionService } from './services/subscription';
export { salesService } from './services/sales';
export { inventoryService } from './services/inventory';
// New API Services
export { trainingService } from './services/training';
export { alertService as alertProcessorService } from './services/alertService';
export { suppliersService } from './services/suppliers';
export { OrdersService } from './services/orders';
export { forecastingService } from './services/forecasting';
export { productionService } from './services/production';
export { posService } from './services/pos';
export { recipesService } from './services/recipes';
// NEW: Sprint 2 & 3 Services
export { ProcurementService } from './services/procurement-service';
export * as orchestratorService from './services/orchestrator';
// Types - Auth
export type {
User,
UserRegistration,
UserLogin,
TokenResponse,
RefreshTokenRequest,
PasswordChange,
PasswordReset,
UserResponse,
UserUpdate as AuthUserUpdate,
TokenVerificationResponse,
AuthHealthResponse,
} from './types/auth';
// Types - User
export type {
UserUpdate,
AdminDeleteRequest,
AdminDeleteResponse,
} from './types/user';
// Types - Onboarding
export type {
OnboardingStepStatus,
UserProgress,
UpdateStepRequest,
} from './types/onboarding';
// Types - Tenant
export type {
BakeryRegistration,
TenantResponse,
TenantAccessResponse,
TenantUpdate,
TenantMemberResponse,
TenantStatistics,
TenantSearchParams,
TenantNearbyParams,
} from './types/tenant';
// Types - Subscription
export type {
SubscriptionLimits,
FeatureCheckResponse,
UsageCheckResponse,
UsageSummary,
AvailablePlans,
Plan,
PlanUpgradeValidation,
PlanUpgradeResult,
SubscriptionTier,
BillingCycle,
PlanMetadata
} from './types/subscription';
export {
SUBSCRIPTION_TIERS,
BILLING_CYCLES,
ANALYTICS_LEVELS
} from './types/subscription';
// Types - Sales
export type {
SalesDataCreate,
SalesDataUpdate,
SalesDataResponse,
SalesDataQuery,
SalesAnalytics,
SalesValidationRequest,
} from './types/sales';
// Types - Data Import
export type {
ImportValidationRequest,
ImportValidationResponse,
ImportProcessRequest,
ImportProcessResponse,
ImportStatusResponse,
} from './types/dataImport';
// Types - Inventory
export type {
IngredientCreate,
IngredientUpdate,
IngredientResponse,
StockCreate,
StockUpdate,
StockResponse,
StockMovementCreate,
StockMovementResponse,
InventoryFilter,
StockFilter,
StockConsumptionRequest,
StockConsumptionResponse,
PaginatedResponse,
} from './types/inventory';
export { ProductType } from './types/inventory';
// Types - Classification
export type {
ProductClassificationRequest,
BatchClassificationRequest,
ProductSuggestionResponse,
BusinessModelAnalysisResponse,
ClassificationApprovalRequest,
ClassificationApprovalResponse,
} from './types/classification';
// Types - Dashboard
export type {
InventoryDashboardSummary,
InventoryAnalytics,
BusinessModelInsights,
DashboardFilter,
AlertsFilter,
RecentActivity,
StockMovementSummary,
CategorySummary,
AlertSummary,
StockStatusSummary,
} from './types/dashboard';
// Types - Food Safety
export type {
FoodSafetyComplianceCreate,
FoodSafetyComplianceUpdate,
FoodSafetyComplianceResponse,
TemperatureLogCreate,
BulkTemperatureLogCreate,
TemperatureLogResponse,
FoodSafetyAlertCreate,
FoodSafetyAlertUpdate,
FoodSafetyAlertResponse,
FoodSafetyFilter,
TemperatureMonitoringFilter,
FoodSafetyMetrics,
TemperatureAnalytics,
FoodSafetyDashboard,
} from './types/foodSafety';
// Types - Training
export type {
TrainingJobRequest,
TrainingJobResponse,
TrainingJobStatus,
SingleProductTrainingRequest,
TrainingResults,
TrainingMetrics,
ActiveModelResponse,
ModelMetricsResponse,
TrainedModelResponse,
TenantStatistics as TrainingTenantStatistics,
ModelPerformanceResponse,
TrainingProgressMessage,
TrainingCompletedMessage,
TrainingErrorMessage,
TrainingWebSocketMessage,
ModelsQueryParams,
} from './types/training';
export { TrainingStatus } from './types/training';
// Types - Alert Processor
export type {
EventResponse as AlertResponse,
EventQueryParams as AlertQueryParams,
NotificationSettings,
ChannelRoutingConfig,
WebhookConfig,
AlertProcessingStatus,
ProcessingMetrics,
AlertAction,
BusinessHours,
} from './types/events';
// No need for additional enums as they are included in events.ts
// Types - Suppliers
export type {
SupplierCreate,
SupplierUpdate,
SupplierResponse,
SupplierSummary,
SupplierApproval,
SupplierQueryParams,
SupplierStatistics,
TopSuppliersResponse,
PurchaseOrderCreate,
PurchaseOrderUpdate,
PurchaseOrderResponse,
PurchaseOrderApproval,
PurchaseOrderQueryParams,
DeliveryCreate,
DeliveryUpdate,
DeliveryResponse,
DeliveryReceiptConfirmation,
DeliveryQueryParams,
PerformanceCalculationRequest,
PerformanceMetrics,
PerformanceAlert,
PurchaseOrderItem,
DeliveryItem,
} from './types/suppliers';
export {
SupplierType,
SupplierStatus,
PaymentTerms,
PurchaseOrderStatus,
DeliveryStatus,
OrderPriority,
PerformanceMetricType,
} from './types/suppliers';
// Types - Orders
export type {
CustomerType,
DeliveryMethod,
PaymentTerms as OrdersPaymentTerms,
PaymentMethod,
PaymentStatus,
CustomerSegment,
PriorityLevel,
OrderType,
OrderStatus,
OrderSource,
SalesChannel,
BusinessModel,
CustomerBase,
CustomerCreate,
CustomerUpdate,
CustomerResponse,
OrderItemBase,
OrderItemCreate,
OrderItemUpdate,
OrderItemResponse,
OrderBase,
OrderCreate,
OrderUpdate,
OrderResponse,
OrdersDashboardSummary,
DemandRequirements,
BusinessModelDetection,
ServiceStatus,
GetOrdersParams,
GetCustomersParams,
UpdateOrderStatusParams,
GetDemandRequirementsParams,
} from './types/orders';
// Types - Procurement
export type {
// Enums
ProcurementPlanType,
ProcurementStrategy,
RiskLevel,
RequirementStatus,
PlanStatus,
DeliveryStatus as ProcurementDeliveryStatus,
PriorityLevel as ProcurementPriorityLevel,
BusinessModel as ProcurementBusinessModel,
// Requirement types
ProcurementRequirementBase,
ProcurementRequirementCreate,
ProcurementRequirementUpdate,
ProcurementRequirementResponse,
// Plan types
ProcurementPlanBase,
ProcurementPlanCreate,
ProcurementPlanUpdate,
ProcurementPlanResponse,
ApprovalWorkflowEntry,
// Dashboard & Analytics
ProcurementSummary,
ProcurementDashboardData,
// Request/Response types
GeneratePlanRequest,
GeneratePlanResponse,
AutoGenerateProcurementRequest,
AutoGenerateProcurementResponse,
CreatePOsResult,
LinkRequirementToPORequest,
UpdateDeliveryStatusRequest,
ApprovalRequest,
RejectionRequest,
PaginatedProcurementPlans,
ForecastRequest as ProcurementForecastRequest,
// Query params
GetProcurementPlansParams,
GetPlanRequirementsParams,
UpdatePlanStatusParams,
} from './types/procurement';
// Types - Forecasting
export type {
ForecastRequest,
ForecastResponse,
BatchForecastRequest,
BatchForecastResponse,
ForecastStatistics,
ForecastListResponse,
ForecastByIdResponse,
DeleteForecastResponse,
GetForecastsParams,
ForecastingHealthResponse,
} from './types/forecasting';
export { BusinessType } from './types/forecasting';
// Types - Production
export type {
ProductionBatchBase,
ProductionBatchCreate,
ProductionBatchUpdate,
ProductionBatchStatusUpdate,
ProductionBatchResponse,
ProductionScheduleBase,
ProductionScheduleCreate,
ProductionScheduleUpdate,
ProductionScheduleResponse,
QualityCheckBase,
QualityCheckCreate,
QualityCheckResponse,
ProductionDashboardSummary,
DailyProductionRequirements,
ProductionMetrics,
ProductionBatchListResponse,
ProductionScheduleListResponse,
QualityCheckListResponse,
ProductionScheduleData,
ProductionCapacityStatus,
ProductionRequirements,
ProductionYieldMetrics,
} from './types/production';
export {
ProductionStatusEnum,
ProductionPriorityEnum,
ProductionBatchStatus,
QualityCheckStatus,
} from './types/production';
// Types - POS
export type {
POSConfiguration,
POSTransaction,
POSTransactionItem,
POSWebhookLog,
POSSyncLog,
POSSystemInfo,
POSProviderConfig,
POSCredentialsField,
GetPOSConfigurationsRequest,
GetPOSConfigurationsResponse,
CreatePOSConfigurationRequest,
CreatePOSConfigurationResponse,
UpdatePOSConfigurationRequest,
UpdatePOSConfigurationResponse,
TestPOSConnectionRequest,
TestPOSConnectionResponse,
POSSyncSettings,
SyncHealth,
SyncAnalytics,
TransactionSummary,
WebhookStatus,
POSSystem,
POSEnvironment,
} from './types/pos';
// Types - Recipes
export type {
RecipeStatus,
MeasurementUnit,
ProductionStatus as RecipeProductionStatus,
ProductionPriority as RecipeProductionPriority,
RecipeIngredientCreate,
RecipeIngredientUpdate,
RecipeIngredientResponse,
RecipeCreate,
RecipeUpdate,
RecipeResponse,
RecipeSearchRequest,
RecipeSearchParams,
RecipeDuplicateRequest,
RecipeFeasibilityResponse,
RecipeStatisticsResponse,
RecipeCategoriesResponse,
ProductionBatchCreate as RecipeProductionBatchCreate,
ProductionBatchUpdate as RecipeProductionBatchUpdate,
ProductionBatchResponse as RecipeProductionBatchResponse,
RecipeFormData,
RecipeUpdateFormData,
} from './types/recipes';
// Hooks - Auth
export {
useAuthProfile,
useAuthHealth,
useVerifyToken,
useLogin,
useRefreshToken,
useLogout,
useChangePassword,
useRequestPasswordReset,
useResetPasswordWithToken,
useUpdateProfile,
useVerifyEmail,
authKeys,
} from './hooks/auth';
// Hooks - User
export {
useCurrentUser,
useAllUsers,
useUserById,
useUpdateUser,
useDeleteUser,
useAdminDeleteUser,
userKeys,
} from './hooks/user';
// Hooks - Onboarding
export {
useUserProgress,
useAllSteps,
useStepDetails,
useUpdateStep,
useMarkStepCompleted,
useResetProgress,
onboardingKeys,
} from './hooks/onboarding';
// Hooks - Tenant
export {
useTenant,
useTenantBySubdomain,
useUserTenants,
useUserOwnedTenants,
useTenantAccess,
useSearchTenants,
useNearbyTenants,
useTeamMembers,
useTenantStatistics,
useRegisterBakery,
useUpdateTenant,
useDeactivateTenant,
useActivateTenant,
useUpdateModelStatus,
useAddTeamMember,
useUpdateMemberRole,
useRemoveTeamMember,
tenantKeys,
} from './hooks/tenant';
// Hooks - Sales
export {
useSalesRecords,
useSalesRecord,
useSalesAnalytics,
useProductSales,
useProductCategories,
useCreateSalesRecord,
useUpdateSalesRecord,
useDeleteSalesRecord,
useValidateSalesRecord,
salesKeys,
} from './hooks/sales';
// Hooks - Inventory
export {
useIngredients,
useIngredient,
useIngredientsByCategory,
useLowStockIngredients,
useStock,
useStockByIngredient,
useExpiringStock,
useExpiredStock,
useStockMovements,
useStockAnalytics,
useCreateIngredient,
useUpdateIngredient,
useSoftDeleteIngredient,
useHardDeleteIngredient,
useAddStock,
useUpdateStock,
useConsumeStock,
useCreateStockMovement,
inventoryKeys,
} from './hooks/inventory';
// Note: Classification hooks consolidated into inventory.ts hooks (useClassifyBatch)
// Note: Data Import hooks consolidated into sales.ts hooks (useValidateImportFile, useImportSalesData)
// Note: Inventory Dashboard and Food Safety hooks consolidated into inventory.ts hooks
// Hooks - Training
export {
useTrainingJobStatus,
useActiveModel,
useModels,
useModelMetrics,
useModelPerformance,
useTenantTrainingStatistics,
useCreateTrainingJob,
useTrainSingleProduct,
useDeleteAllTenantModels,
useTrainingWebSocket,
useIsTrainingInProgress,
useTrainingProgress,
trainingKeys,
} from './hooks/training';
// Hooks - Alert Processor
export {
useEvents as useAlerts,
useEvent as useAlert,
useEventsSummary as useAlertDashboardData,
useAcknowledgeAlert,
useResolveAlert,
useCancelAutoAction,
useDismissRecommendation,
useBulkAcknowledgeAlerts,
useBulkResolveAlerts,
useRecordInteraction,
alertKeys as alertProcessorKeys,
} from './hooks/useAlerts';
// Hooks - Unified Alerts
export {
useUnifiedAlerts,
useSingleAlert,
useAlertStats,
useRealTimeAlerts,
} from './hooks/useUnifiedAlerts';
// Hooks - Suppliers
export {
useSuppliers,
useSupplier,
useSupplierStatistics,
useActiveSuppliers,
useTopSuppliers,
usePendingApprovalSuppliers,
useSuppliersByType,
useDeliveries,
useDelivery,
useSupplierPerformanceMetrics,
usePerformanceAlerts,
useCreateSupplier,
useUpdateSupplier,
useDeleteSupplier,
useApproveSupplier,
useCreateDelivery,
useUpdateDelivery,
useConfirmDeliveryReceipt,
useCalculateSupplierPerformance,
useEvaluatePerformanceAlerts,
useSuppliersByStatus,
useSuppliersCount,
useActiveSuppliersCount,
usePendingOrdersCount,
suppliersKeys,
} from './hooks/suppliers';
// Hooks - Orders
export {
useOrders,
useOrder,
useCustomers,
useCustomer,
useOrdersDashboard,
useDemandRequirements,
useBusinessModelDetection,
useOrdersServiceStatus,
useCreateOrder,
useUpdateOrder,
useUpdateOrderStatus,
useCreateCustomer,
useUpdateCustomer,
useInvalidateOrders,
ordersKeys,
} from './hooks/orders';
// Hooks - Procurement
export {
// Queries
useProcurementDashboard,
useProcurementPlans,
useProcurementPlan,
useProcurementPlanByDate,
useCurrentProcurementPlan,
usePlanRequirements,
useCriticalRequirements,
// Mutations
useGenerateProcurementPlan,
useAutoGenerateProcurement,
useUpdateProcurementPlanStatus,
useRecalculateProcurementPlan,
useApproveProcurementPlan,
useRejectProcurementPlan,
useCreatePurchaseOrdersFromPlan,
useLinkRequirementToPurchaseOrder,
useUpdateRequirementDeliveryStatus,
// Query keys
procurementKeys,
} from './hooks/procurement';
// Hooks - Forecasting
export {
useTenantForecasts,
useForecastById,
useForecastStatistics,
useForecastingHealth,
useInfiniteTenantForecasts,
useCreateSingleForecast,
useCreateBatchForecast,
useDeleteForecast,
usePrefetchForecast,
useInvalidateForecasting,
forecastingKeys,
} from './hooks/forecasting';
// Hooks - Production
export {
useProductionDashboard,
useDailyProductionRequirements,
useProductionRequirements,
useActiveBatches,
useBatchDetails,
useProductionSchedule,
useCapacityStatus,
useYieldMetrics,
useCreateProductionBatch,
useUpdateBatchStatus,
useProductionDashboardData,
useProductionPlanningData,
useTriggerProductionScheduler,
productionKeys,
} from './hooks/production';
// Hooks - POS
export {
usePOSConfigurations,
usePOSConfiguration,
useSupportedPOSSystems,
useCreatePOSConfiguration,
useUpdatePOSConfiguration,
useDeletePOSConfiguration,
useTestPOSConnection,
usePOSTransactions,
usePOSTransaction,
useTriggerManualSync,
usePOSSyncStatus,
useDetailedSyncLogs,
useSyncSingleTransaction,
usePOSSyncAnalytics,
useResyncFailedTransactions,
usePOSSyncLogs,
usePOSWebhookLogs,
useWebhookStatus,
usePOSUtils,
usePOSConfigurationData,
usePOSConfigurationManager,
posKeys,
} from './hooks/pos';
// Hooks - Recipes
export {
useRecipe,
useRecipes,
useInfiniteRecipes,
useRecipeStatistics,
useRecipeCategories,
useRecipeFeasibility,
useCreateRecipe,
useUpdateRecipe,
useDeleteRecipe,
useDuplicateRecipe,
useActivateRecipe,
recipesKeys,
} from './hooks/recipes';
// Hooks - Orchestrator
export {
useRunDailyWorkflow,
} from './hooks/orchestrator';
// Hooks - Professional Dashboard (JTBD-aligned)
export {
useBakeryHealthStatus,
useOrchestrationSummary,
useProductionTimeline,
useApprovePurchaseOrder as useApprovePurchaseOrderDashboard,
useStartProductionBatch,
usePauseProductionBatch,
useExecutionProgress,
useUnifiedActionQueue,
} from './hooks/useProfessionalDashboard';
export type {
BakeryHealthStatus,
HealthChecklistItem,
HeadlineData,
ReasoningInputs,
PurchaseOrderSummary,
ProductionBatchSummary,
OrchestrationSummary,
ActionButton,
ActionItem,
ActionQueue,
ProductionTimeline,
ProductionTimelineItem,
InsightCard,
Insights,
UnifiedActionQueue,
EnrichedAlert,
} from './hooks/useProfessionalDashboard';
// Hooks - Enterprise Dashboard
export {
useNetworkSummary,
useChildrenPerformance,
useDistributionOverview,
useForecastSummary,
useChildSales,
useChildInventory,
useChildProduction,
useChildTenants,
} from './hooks/useEnterpriseDashboard';
export type {
NetworkSummary,
PerformanceRankings,
ChildPerformance,
DistributionOverview,
ForecastSummary,
ChildTenant,
SalesSummary,
InventorySummary,
ProductionSummary,
} from './hooks/useEnterpriseDashboard';
// Note: All query key factories are already exported in their respective hook sections above

View File

@@ -0,0 +1,452 @@
/**
* AI Insights Service
*
* Provides access to AI-generated insights from the AI Insights microservice.
* Replaces mock data with real API integration.
*
* Backend endpoints:
* - GET /tenants/{tenant_id}/insights
* - GET /tenants/{tenant_id}/insights/{insight_id}
* - POST /tenants/{tenant_id}/insights/feedback
* - GET /tenants/{tenant_id}/insights/stats
* - GET /tenants/{tenant_id}/insights/orchestration-ready
*
* Last Updated: 2025-11-03
* Status: ✅ Complete - Real API Integration
*/
import { apiClient } from '../client';
import { useTenantStore } from '../../stores/tenant.store';
import { getTenantCurrencySymbol } from '../../hooks/useTenantCurrency';
export interface AIInsight {
id: string;
tenant_id: string;
type: 'optimization' | 'alert' | 'prediction' | 'recommendation' | 'insight' | 'anomaly';
priority: 'low' | 'medium' | 'high' | 'critical';
category: 'forecasting' | 'inventory' | 'production' | 'procurement' | 'customer' | 'cost' | 'quality' | 'efficiency' | 'demand' | 'maintenance' | 'energy' | 'scheduling';
title: string;
description: string;
impact_type?: 'cost_savings' | 'revenue_increase' | 'waste_reduction' | 'efficiency_gain' | 'quality_improvement' | 'risk_mitigation';
impact_value?: number;
impact_unit?: string;
confidence: number;
metrics_json: Record<string, any>;
actionable: boolean;
recommendation_actions?: Array<{
label: string;
action: string;
endpoint?: string;
}>;
source_service?: string;
source_data_id?: string;
status: 'new' | 'acknowledged' | 'in_progress' | 'applied' | 'dismissed' | 'expired';
created_at: string;
updated_at: string;
applied_at?: string;
expired_at?: string;
}
export interface AIInsightFilters {
type?: 'optimization' | 'alert' | 'prediction' | 'recommendation' | 'insight' | 'anomaly';
priority?: 'low' | 'medium' | 'high' | 'critical';
status?: 'new' | 'acknowledged' | 'in_progress' | 'applied' | 'dismissed' | 'expired';
category?: 'forecasting' | 'inventory' | 'production' | 'procurement' | 'customer' | 'cost' | 'quality' | 'efficiency' | 'demand' | 'maintenance' | 'energy' | 'scheduling';
actionable_only?: boolean;
min_confidence?: number;
source_service?: string;
from_date?: string;
to_date?: string;
limit?: number;
offset?: number;
}
export interface AIInsightListResponse {
items: AIInsight[];
total: number;
limit: number;
offset: number;
has_more: boolean;
}
export interface AIInsightStatsResponse {
total_insights: number;
actionable_insights: number;
average_confidence: number;
high_priority_count: number;
medium_priority_count: number;
low_priority_count: number;
critical_priority_count: number;
by_category: Record<string, number>;
by_status: Record<string, number>;
total_potential_impact?: number;
}
export interface FeedbackRequest {
applied: boolean;
applied_at?: string;
outcome_date?: string;
outcome_metrics?: Record<string, any>;
user_rating?: number;
user_comment?: string;
}
export interface FeedbackResponse {
insight_id: string;
feedback_recorded: boolean;
feedback_id: string;
recorded_at: string;
}
export interface OrchestrationReadyInsightsRequest {
target_date: string;
min_confidence?: number;
}
export interface OrchestrationReadyInsightsResponse {
target_date: string;
insights: AIInsight[];
categorized_insights: {
demand_forecasts: AIInsight[];
supplier_alerts: AIInsight[];
inventory_optimizations: AIInsight[];
price_opportunities: AIInsight[];
yield_predictions: AIInsight[];
business_rules: AIInsight[];
other: AIInsight[];
};
total_insights: number;
}
export class AIInsightsService {
private readonly baseUrl = '/tenants';
/**
* Get all AI insights for a tenant with optional filters
*/
async getInsights(
tenantId: string,
filters?: AIInsightFilters
): Promise<AIInsightListResponse> {
const queryParams = new URLSearchParams();
if (filters?.type) queryParams.append('type', filters.type);
if (filters?.priority) queryParams.append('priority', filters.priority);
if (filters?.category) queryParams.append('category', filters.category);
if (filters?.status) queryParams.append('status', filters.status);
if (filters?.min_confidence) queryParams.append('min_confidence', filters.min_confidence.toString());
if (filters?.actionable_only) queryParams.append('actionable_only', 'true');
if (filters?.source_service) queryParams.append('source_service', filters.source_service);
if (filters?.from_date) queryParams.append('from_date', filters.from_date);
if (filters?.to_date) queryParams.append('to_date', filters.to_date);
if (filters?.limit) queryParams.append('limit', filters.limit.toString());
if (filters?.offset) queryParams.append('offset', filters.offset.toString());
const url = `${this.baseUrl}/${tenantId}/insights${queryParams.toString() ? '?' + queryParams.toString() : ''}`;
return apiClient.get<AIInsightListResponse>(url);
}
/**
* Get a single insight by ID
*/
async getInsight(
tenantId: string,
insightId: string
): Promise<AIInsight> {
const url = `${this.baseUrl}/${tenantId}/insights/${insightId}`;
return apiClient.get<AIInsight>(url);
}
/**
* Get insight statistics
*/
async getInsightStats(
tenantId: string,
filters?: {
start_date?: string;
end_date?: string;
}
): Promise<AIInsightStatsResponse> {
const queryParams = new URLSearchParams();
if (filters?.start_date) queryParams.append('start_date', filters.start_date);
if (filters?.end_date) queryParams.append('end_date', filters.end_date);
const url = `${this.baseUrl}/${tenantId}/insights/metrics/summary${queryParams.toString() ? '?' + queryParams.toString() : ''}`;
return apiClient.get<AIInsightStatsResponse>(url);
}
/**
* Get orchestration-ready insights for a specific date
*/
async getOrchestrationReadyInsights(
tenantId: string,
request: OrchestrationReadyInsightsRequest
): Promise<OrchestrationReadyInsightsResponse> {
const url = `${this.baseUrl}/${tenantId}/insights/orchestration-ready`;
const queryParams = new URLSearchParams();
queryParams.append('target_date', request.target_date);
if (request.min_confidence) {
queryParams.append('min_confidence', request.min_confidence.toString());
}
return apiClient.get<OrchestrationReadyInsightsResponse>(
`${url}?${queryParams.toString()}`
);
}
/**
* Record feedback for an applied insight
*/
async recordFeedback(
tenantId: string,
insightId: string,
feedback: FeedbackRequest
): Promise<FeedbackResponse> {
const url = `${this.baseUrl}/${tenantId}/insights/${insightId}/feedback`;
return apiClient.post<FeedbackResponse>(url, feedback);
}
/**
* Apply an insight (mark as applied)
*/
async applyInsight(
tenantId: string,
insightId: string
): Promise<AIInsight> {
const url = `${this.baseUrl}/${tenantId}/insights/${insightId}/apply`;
return apiClient.post<AIInsight>(url, {
applied_at: new Date().toISOString(),
});
}
/**
* Dismiss an insight
*/
async dismissInsight(
tenantId: string,
insightId: string
): Promise<void> {
const url = `${this.baseUrl}/${tenantId}/insights/${insightId}`;
return apiClient.delete(url);
}
/**
* Update an insight status (acknowledge, apply, etc.)
*/
async updateInsightStatus(
tenantId: string,
insightId: string,
status: 'acknowledged' | 'in_progress' | 'applied' | 'expired'
): Promise<AIInsight> {
const url = `${this.baseUrl}/${tenantId}/insights/${insightId}`;
return apiClient.patch<AIInsight>(url, { status });
}
/**
* Get insights by priority (for dashboard widgets)
*/
async getHighPriorityInsights(
tenantId: string,
limit: number = 10
): Promise<AIInsight[]> {
// Fetch critical priority insights first
const response = await this.getInsights(tenantId, {
priority: 'critical',
status: 'new',
limit,
});
if (response.items.length < limit) {
// Add high priority if not enough critical
const highPriorityResponse = await this.getInsights(tenantId, {
priority: 'high',
status: 'new',
limit: limit - response.items.length,
});
return [...response.items, ...highPriorityResponse.items];
}
return response.items;
}
/**
* Get actionable insights (for recommendations panel)
*/
async getActionableInsights(
tenantId: string,
limit: number = 20
): Promise<AIInsight[]> {
const response = await this.getInsights(tenantId, {
actionable_only: true,
status: 'new',
limit,
});
return response.items;
}
/**
* Get insights by category
*/
async getInsightsByCategory(
tenantId: string,
category: string,
limit: number = 20
): Promise<AIInsight[]> {
const response = await this.getInsights(tenantId, {
category: category as any, // Category comes from user input
status: 'new',
limit,
});
return response.items;
}
/**
* Search insights
*/
async searchInsights(
tenantId: string,
query: string,
filters?: Partial<AIInsightFilters>
): Promise<AIInsight[]> {
// Note: search parameter not supported by backend API
// This is a client-side workaround - fetch all and filter
const response = await this.getInsights(tenantId, {
...filters,
limit: filters?.limit || 50,
});
// Filter by query on client side
const lowerQuery = query.toLowerCase();
return response.items.filter(
(insight) =>
insight.title.toLowerCase().includes(lowerQuery) ||
insight.description.toLowerCase().includes(lowerQuery)
);
}
/**
* Get recent insights (for activity feed)
*/
async getRecentInsights(
tenantId: string,
days: number = 7,
limit: number = 50
): Promise<AIInsight[]> {
const endDate = new Date();
const startDate = new Date();
startDate.setDate(startDate.getDate() - days);
const response = await this.getInsights(tenantId, {
start_date: startDate.toISOString(),
end_date: endDate.toISOString(),
limit,
});
return response.items;
}
/**
* Get insights summary for dashboard
*/
async getDashboardSummary(
tenantId: string
): Promise<{
stats: AIInsightStatsResponse;
highPriority: AIInsight[];
recent: AIInsight[];
}> {
const [stats, highPriority, recent] = await Promise.all([
this.getInsightStats(tenantId),
this.getHighPriorityInsights(tenantId, 5),
this.getRecentInsights(tenantId, 7, 10),
]);
return {
stats,
highPriority,
recent,
};
}
/**
* Format impact value for display
*/
formatImpactValue(insight: AIInsight): string {
if (!insight.impact_value) return 'N/A';
const value = insight.impact_value;
const unit = insight.impact_unit || 'units';
const currencySymbol = getTenantCurrencySymbol(useTenantStore.getState().currentTenant?.currency);
if (unit === 'euros_per_year' || unit === 'eur') {
return `${currencySymbol}${value.toLocaleString('en-US', { minimumFractionDigits: 0, maximumFractionDigits: 0 })}/year`;
} else if (unit === 'euros') {
return `${currencySymbol}${value.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
} else if (unit === 'percentage' || unit === 'percentage_points') {
return `${value.toFixed(1)}%`;
} else if (unit === 'units') {
return `${value.toFixed(0)} units`;
} else {
return `${value.toFixed(2)} ${unit}`;
}
}
/**
* Get priority badge color
*/
getPriorityColor(priority: string): string {
switch (priority) {
case 'urgent':
return 'red';
case 'high':
return 'orange';
case 'medium':
return 'yellow';
case 'low':
return 'blue';
default:
return 'gray';
}
}
/**
* Get type icon
*/
getTypeIcon(type: string): string {
switch (type) {
case 'forecast':
return '📈';
case 'warning':
return '⚠️';
case 'opportunity':
return '💡';
case 'positive':
return '✅';
case 'optimization':
return '🎯';
case 'rule':
return '📋';
default:
return '📊';
}
}
/**
* Calculate confidence color
*/
getConfidenceColor(confidence: number): string {
if (confidence >= 90) return 'green';
if (confidence >= 75) return 'blue';
if (confidence >= 60) return 'yellow';
return 'red';
}
}
// Export singleton instance
export const aiInsightsService = new AIInsightsService();

View File

@@ -0,0 +1,253 @@
/**
* Clean Alert Service - Matches Backend API Exactly
*
* Backend API: /services/alert_processor/app/api/alerts_clean.py
*
* NO backward compatibility, uses new type system from /api/types/events.ts
*/
import { apiClient } from '../client';
import type {
EventResponse,
Alert,
Notification,
Recommendation,
PaginatedResponse,
EventsSummary,
EventQueryParams,
} from '../types/events';
const BASE_PATH = '/tenants';
// ============================================================
// QUERY METHODS
// ============================================================
/**
* Get events list with filtering and pagination
*/
export async function getEvents(
tenantId: string,
params?: EventQueryParams
): Promise<PaginatedResponse<EventResponse>> {
return await apiClient.get<PaginatedResponse<EventResponse>>(
`${BASE_PATH}/${tenantId}/alerts`,
{ params }
);
}
/**
* Get single event by ID
*/
export async function getEvent(
tenantId: string,
eventId: string
): Promise<EventResponse> {
return await apiClient.get<EventResponse>(
`${BASE_PATH}/${tenantId}/alerts/${eventId}`
);
}
/**
* Get events summary for dashboard
*/
export async function getEventsSummary(
tenantId: string
): Promise<EventsSummary> {
return await apiClient.get<EventsSummary>(
`${BASE_PATH}/${tenantId}/alerts/summary`
);
}
// ============================================================
// MUTATION METHODS - Alerts
// ============================================================
export interface AcknowledgeAlertResponse {
success: boolean;
event_id: string;
status: string;
}
/**
* Acknowledge an alert
*/
export async function acknowledgeAlert(
tenantId: string,
alertId: string
): Promise<AcknowledgeAlertResponse> {
return await apiClient.post<AcknowledgeAlertResponse>(
`${BASE_PATH}/${tenantId}/alerts/${alertId}/acknowledge`
);
}
export interface ResolveAlertResponse {
success: boolean;
event_id: string;
status: string;
resolved_at: string;
}
/**
* Resolve an alert
*/
export async function resolveAlert(
tenantId: string,
alertId: string
): Promise<ResolveAlertResponse> {
return await apiClient.post<ResolveAlertResponse>(
`${BASE_PATH}/${tenantId}/alerts/${alertId}/resolve`
);
}
export interface CancelAutoActionResponse {
success: boolean;
event_id: string;
message: string;
updated_type_class: string;
}
/**
* Cancel an alert's auto-action (escalation countdown)
*/
export async function cancelAutoAction(
tenantId: string,
alertId: string
): Promise<CancelAutoActionResponse> {
return await apiClient.post<CancelAutoActionResponse>(
`${BASE_PATH}/${tenantId}/alerts/${alertId}/cancel-auto-action`
);
}
// ============================================================
// MUTATION METHODS - Recommendations
// ============================================================
export interface DismissRecommendationResponse {
success: boolean;
event_id: string;
dismissed_at: string;
}
/**
* Dismiss a recommendation
*/
export async function dismissRecommendation(
tenantId: string,
recommendationId: string
): Promise<DismissRecommendationResponse> {
return await apiClient.post<DismissRecommendationResponse>(
`${BASE_PATH}/${tenantId}/recommendations/${recommendationId}/dismiss`
);
}
// ============================================================
// INTERACTION TRACKING
// ============================================================
export interface RecordInteractionResponse {
success: boolean;
interaction_id: string;
event_id: string;
interaction_type: string;
}
/**
* Record user interaction with an event (for analytics)
*/
export async function recordInteraction(
tenantId: string,
eventId: string,
interactionType: string,
metadata?: Record<string, any>
): Promise<RecordInteractionResponse> {
return await apiClient.post<RecordInteractionResponse>(
`${BASE_PATH}/${tenantId}/events/${eventId}/interactions`,
{
interaction_type: interactionType,
interaction_metadata: metadata,
}
);
}
// ============================================================
// BULK OPERATIONS (by metadata)
// ============================================================
export interface BulkAcknowledgeResponse {
success: boolean;
acknowledged_count: number;
alert_ids: string[];
}
/**
* Acknowledge multiple alerts by metadata filter
*/
export async function acknowledgeAlertsByMetadata(
tenantId: string,
alertType: string,
metadataFilter: Record<string, any>
): Promise<BulkAcknowledgeResponse> {
return await apiClient.post<BulkAcknowledgeResponse>(
`${BASE_PATH}/${tenantId}/alerts/bulk-acknowledge`,
{
alert_type: alertType,
metadata_filter: metadataFilter,
}
);
}
export interface BulkResolveResponse {
success: boolean;
resolved_count: number;
alert_ids: string[];
}
/**
* Resolve multiple alerts by metadata filter
*/
export async function resolveAlertsByMetadata(
tenantId: string,
alertType: string,
metadataFilter: Record<string, any>
): Promise<BulkResolveResponse> {
return await apiClient.post<BulkResolveResponse>(
`${BASE_PATH}/${tenantId}/alerts/bulk-resolve`,
{
alert_type: alertType,
metadata_filter: metadataFilter,
}
);
}
// ============================================================
// EXPORT AS NAMED OBJECT
// ============================================================
export const alertService = {
// Query
getEvents,
getEvent,
getEventsSummary,
// Alert mutations
acknowledgeAlert,
resolveAlert,
cancelAutoAction,
// Recommendation mutations
dismissRecommendation,
// Interaction tracking
recordInteraction,
// Bulk operations
acknowledgeAlertsByMetadata,
resolveAlertsByMetadata,
};
// ============================================================
// DEFAULT EXPORT
// ============================================================
export default alertService;

View File

@@ -0,0 +1,125 @@
/**
* Alert Analytics API Client
* Handles all API calls for alert analytics and interaction tracking
*/
import { apiClient } from '../client';
export interface AlertTrendData {
date: string;
count: number;
urgentCount: number;
highCount: number;
mediumCount: number;
lowCount: number;
}
export interface AlertCategory {
category: string;
count: number;
percentage: number;
}
export interface AlertAnalytics {
trends: AlertTrendData[];
averageResponseTime: number;
topCategories: AlertCategory[];
totalAlerts: number;
resolvedAlerts: number;
activeAlerts: number;
resolutionRate: number;
predictedDailyAverage: number;
busiestDay: string;
}
export interface AlertInteraction {
alert_id: string;
interaction_type: 'acknowledged' | 'resolved' | 'snoozed' | 'dismissed';
metadata?: Record<string, any>;
}
export interface InteractionResponse {
id: string;
alert_id: string;
interaction_type: string;
interacted_at: string;
response_time_seconds: number;
}
export interface BatchInteractionResponse {
created_count: number;
interactions: Array<{
id: string;
alert_id: string;
interaction_type: string;
interacted_at: string;
}>;
}
/**
* Track a single alert interaction
*/
export async function trackAlertInteraction(
tenantId: string,
alertId: string,
interactionType: 'acknowledged' | 'resolved' | 'snoozed' | 'dismissed',
metadata?: Record<string, any>
): Promise<InteractionResponse> {
return apiClient.post<InteractionResponse>(
`/tenants/${tenantId}/alerts/${alertId}/interactions`,
{
alert_id: alertId,
interaction_type: interactionType,
metadata
}
);
}
/**
* Track multiple alert interactions in batch
*/
export async function trackAlertInteractionsBatch(
tenantId: string,
interactions: AlertInteraction[]
): Promise<BatchInteractionResponse> {
return apiClient.post<BatchInteractionResponse>(
`/tenants/${tenantId}/alerts/interactions/batch`,
{
interactions
}
);
}
/**
* Get comprehensive alert analytics
*/
export async function getAlertAnalytics(
tenantId: string,
days: number = 7
): Promise<AlertAnalytics> {
console.log('[getAlertAnalytics] Calling API:', `/tenants/${tenantId}/alerts/analytics`, 'with days:', days);
const data = await apiClient.get<AlertAnalytics>(
`/tenants/${tenantId}/alerts/analytics`,
{
params: { days }
}
);
console.log('[getAlertAnalytics] Received data:', data);
console.log('[getAlertAnalytics] Data type:', typeof data);
return data; // apiClient.get() already returns data, not response.data
}
/**
* Get alert trends only
*/
export async function getAlertTrends(
tenantId: string,
days: number = 7
): Promise<AlertTrendData[]> {
return apiClient.get<AlertTrendData[]>(
`/tenants/${tenantId}/alerts/analytics/trends`,
{
params: { days }
}
);
}

View File

@@ -0,0 +1,267 @@
// ================================================================
// frontend/src/api/services/auditLogs.ts
// ================================================================
/**
* Audit Logs Aggregation Service
*
* Aggregates audit logs from all microservices and provides
* unified access to system event history.
*
* Backend endpoints:
* - GET /tenants/{tenant_id}/{service}/audit-logs
* - GET /tenants/{tenant_id}/{service}/audit-logs/stats
*
* Last Updated: 2025-11-02
* Status: ✅ Complete - Multi-service aggregation
*/
import { apiClient } from '../client';
import {
AuditLogResponse,
AuditLogFilters,
AuditLogListResponse,
AuditLogStatsResponse,
AggregatedAuditLog,
AUDIT_LOG_SERVICES,
AuditLogServiceName,
} from '../types/auditLogs';
export class AuditLogsService {
private readonly baseUrl = '/tenants';
/**
* Get audit logs from a single service
*/
async getServiceAuditLogs(
tenantId: string,
serviceName: AuditLogServiceName,
filters?: AuditLogFilters
): Promise<AuditLogListResponse> {
const queryParams = new URLSearchParams();
if (filters?.start_date) queryParams.append('start_date', filters.start_date);
if (filters?.end_date) queryParams.append('end_date', filters.end_date);
if (filters?.user_id) queryParams.append('user_id', filters.user_id);
if (filters?.action) queryParams.append('action', filters.action);
if (filters?.resource_type) queryParams.append('resource_type', filters.resource_type);
if (filters?.severity) queryParams.append('severity', filters.severity);
if (filters?.search) queryParams.append('search', filters.search);
if (filters?.limit) queryParams.append('limit', filters.limit.toString());
if (filters?.offset) queryParams.append('offset', filters.offset.toString());
const url = `${this.baseUrl}/${tenantId}/${serviceName}/audit-logs${queryParams.toString() ? '?' + queryParams.toString() : ''}`;
return apiClient.get<AuditLogListResponse>(url);
}
/**
* Get audit log statistics from a single service
*/
async getServiceAuditLogStats(
tenantId: string,
serviceName: AuditLogServiceName,
filters?: {
start_date?: string;
end_date?: string;
}
): Promise<AuditLogStatsResponse> {
const queryParams = new URLSearchParams();
if (filters?.start_date) queryParams.append('start_date', filters.start_date);
if (filters?.end_date) queryParams.append('end_date', filters.end_date);
const url = `${this.baseUrl}/${tenantId}/${serviceName}/audit-logs/stats${queryParams.toString() ? '?' + queryParams.toString() : ''}`;
return apiClient.get<AuditLogStatsResponse>(url);
}
/**
* Get aggregated audit logs from ALL services
* Makes parallel requests to all services and combines results
*/
async getAllAuditLogs(
tenantId: string,
filters?: AuditLogFilters
): Promise<AggregatedAuditLog[]> {
// Make parallel requests to all services
const promises = AUDIT_LOG_SERVICES.map(service =>
this.getServiceAuditLogs(tenantId, service, {
...filters,
limit: filters?.limit || 100,
}).catch(error => {
// If a service fails, log the error but don't fail the entire request
console.warn(`Failed to fetch audit logs from ${service}:`, error);
return { items: [], total: 0, limit: 0, offset: 0, has_more: false };
})
);
const results = await Promise.all(promises);
// Combine all results
const allLogs: AggregatedAuditLog[] = results.flatMap(result => result.items);
// Sort by created_at descending (most recent first)
allLogs.sort((a, b) => {
const dateA = new Date(a.created_at).getTime();
const dateB = new Date(b.created_at).getTime();
return dateB - dateA;
});
// Apply limit if specified
const limit = filters?.limit || 100;
const offset = filters?.offset || 0;
return allLogs.slice(offset, offset + limit);
}
/**
* Get aggregated statistics from ALL services
*/
async getAllAuditLogStats(
tenantId: string,
filters?: {
start_date?: string;
end_date?: string;
}
): Promise<AuditLogStatsResponse> {
// Make parallel requests to all services
const promises = AUDIT_LOG_SERVICES.map(service =>
this.getServiceAuditLogStats(tenantId, service, filters).catch(error => {
console.warn(`Failed to fetch audit log stats from ${service}:`, error);
return {
total_events: 0,
events_by_action: {},
events_by_severity: {},
events_by_resource_type: {},
date_range: { min: null, max: null },
};
})
);
const results = await Promise.all(promises);
// Aggregate statistics
const aggregated: AuditLogStatsResponse = {
total_events: 0,
events_by_action: {},
events_by_severity: {},
events_by_resource_type: {},
date_range: { min: null, max: null },
};
for (const result of results) {
aggregated.total_events += result.total_events;
// Merge events_by_action
for (const [action, count] of Object.entries(result.events_by_action)) {
aggregated.events_by_action[action] = (aggregated.events_by_action[action] || 0) + count;
}
// Merge events_by_severity
for (const [severity, count] of Object.entries(result.events_by_severity)) {
aggregated.events_by_severity[severity] = (aggregated.events_by_severity[severity] || 0) + count;
}
// Merge events_by_resource_type
for (const [resource, count] of Object.entries(result.events_by_resource_type)) {
aggregated.events_by_resource_type[resource] = (aggregated.events_by_resource_type[resource] || 0) + count;
}
// Update date range
if (result.date_range.min) {
if (!aggregated.date_range.min || result.date_range.min < aggregated.date_range.min) {
aggregated.date_range.min = result.date_range.min;
}
}
if (result.date_range.max) {
if (!aggregated.date_range.max || result.date_range.max > aggregated.date_range.max) {
aggregated.date_range.max = result.date_range.max;
}
}
}
return aggregated;
}
/**
* Export audit logs to CSV format
*/
exportToCSV(logs: AggregatedAuditLog[]): string {
if (logs.length === 0) return '';
const headers = [
'Timestamp',
'Service',
'User ID',
'Action',
'Resource Type',
'Resource ID',
'Severity',
'Description',
'IP Address',
'Endpoint',
'Method',
];
const rows = logs.map(log => [
log.created_at,
log.service_name,
log.user_id || '',
log.action,
log.resource_type,
log.resource_id || '',
log.severity,
log.description,
log.ip_address || '',
log.endpoint || '',
log.method || '',
]);
const csvContent = [
headers.join(','),
...rows.map(row => row.map(cell => `"${cell}"`).join(',')),
].join('\n');
return csvContent;
}
/**
* Export audit logs to JSON format
*/
exportToJSON(logs: AggregatedAuditLog[]): string {
return JSON.stringify(logs, null, 2);
}
/**
* Download audit logs as a file
*/
downloadAuditLogs(
logs: AggregatedAuditLog[],
format: 'csv' | 'json',
filename?: string
): void {
const content = format === 'csv' ? this.exportToCSV(logs) : this.exportToJSON(logs);
const blob = new Blob([content], {
type: format === 'csv' ? 'text/csv;charset=utf-8;' : 'application/json',
});
const link = document.createElement('a');
const url = URL.createObjectURL(blob);
link.setAttribute('href', url);
link.setAttribute(
'download',
filename || `audit-logs-${new Date().toISOString().split('T')[0]}.${format}`
);
link.style.visibility = 'hidden';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
}
}
// Export singleton instance
export const auditLogsService = new AuditLogsService();

View File

@@ -0,0 +1,258 @@
// ================================================================
// frontend/src/api/services/auth.ts
// ================================================================
/**
* Auth Service - Atomic Registration Architecture
*
* Backend API structure (3-tier architecture):
* - OPERATIONS: auth_operations.py, onboarding_progress.py
*
* Last Updated: 2025-01-14
* Status: Complete - SetupIntent-first registration flow with 3DS support
*/
import { apiClient } from '../client';
import {
UserRegistration,
UserLogin,
TokenResponse,
RefreshTokenRequest,
PasswordChange,
PasswordReset,
UserResponse,
UserUpdate,
TokenVerificationResponse,
AuthHealthResponse,
RegistrationStartResponse,
RegistrationCompletionResponse,
RegistrationVerification,
} from '../types/auth';
export class AuthService {
private readonly baseUrl = '/auth';
// User Profile (authenticated)
// Backend: services/auth/app/api/users.py
// ===================================================================
async getProfile(): Promise<UserResponse> {
// Get current user ID from auth store
const { useAuthStore } = await import('../../stores/auth.store');
const user = useAuthStore.getState().user;
if (!user?.id) {
throw new Error('User not authenticated or user ID not available');
}
return apiClient.get<UserResponse>(`${this.baseUrl}/users/${user.id}`);
}
async updateProfile(updateData: UserUpdate): Promise<UserResponse> {
// Get current user ID from auth store
const { useAuthStore } = await import('../../stores/auth.store');
const user = useAuthStore.getState().user;
if (!user?.id) {
throw new Error('User not authenticated or user ID not available');
}
return apiClient.put<UserResponse>(`${this.baseUrl}/users/${user.id}`, updateData);
}
// ATOMIC REGISTRATION: SetupIntent-First Approach
// These methods implement the secure registration flow with 3DS support
// ===================================================================
/**
* Start secure registration flow with SetupIntent-first approach
* This is the FIRST step in the atomic registration flow
* Backend: services/auth/app/api/auth_operations.py:start_registration()
*/
async startRegistration(userData: UserRegistration): Promise<RegistrationStartResponse> {
return apiClient.post<RegistrationStartResponse>(`${this.baseUrl}/start-registration`, userData);
}
/**
* Complete registration after 3DS verification
* This is the SECOND step in the atomic registration flow
* Backend: services/auth/app/api/auth_operations.py:complete_registration()
*/
async completeRegistration(verificationData: RegistrationVerification): Promise<RegistrationCompletionResponse> {
return apiClient.post<RegistrationCompletionResponse>(`${this.baseUrl}/complete-registration`, verificationData);
}
// ===================================================================
// OPERATIONS: Authentication
// Backend: services/auth/app/api/auth_operations.py
// ===================================================================
async login(loginData: UserLogin): Promise<TokenResponse> {
return apiClient.post<TokenResponse>(`${this.baseUrl}/login`, loginData);
}
async refreshToken(refreshToken: string): Promise<TokenResponse> {
const refreshData: RefreshTokenRequest = { refresh_token: refreshToken };
return apiClient.post<TokenResponse>(`${this.baseUrl}/refresh`, refreshData);
}
async verifyToken(token?: string): Promise<TokenVerificationResponse> {
// If token is provided, temporarily set it; otherwise use current token
const currentToken = apiClient.getAuthToken();
if (token && token !== currentToken) {
apiClient.setAuthToken(token);
}
const response = await apiClient.post<TokenVerificationResponse>(`${this.baseUrl}/verify`);
// Restore original token if we temporarily changed it
if (token && token !== currentToken) {
apiClient.setAuthToken(currentToken);
}
return response;
}
async logout(refreshToken: string): Promise<{ message: string }> {
const refreshData: RefreshTokenRequest = { refresh_token: refreshToken };
return apiClient.post<{ message: string }>(`${this.baseUrl}/logout`, refreshData);
}
async changePassword(passwordData: PasswordChange): Promise<{ message: string }> {
return apiClient.post<{ message: string }>(`${this.baseUrl}/change-password`, passwordData);
}
async requestPasswordReset(email: string): Promise<{ message: string }> {
return apiClient.post<{ message: string }>(`${this.baseUrl}/password/reset-request`, { email });
}
async resetPasswordWithToken(token: string, newPassword: string): Promise<{ message: string }> {
return apiClient.post<{ message: string }>(`${this.baseUrl}/password/reset`, {
token,
new_password: newPassword
});
}
// ===================================================================
// OPERATIONS: Email Verification
// Backend: services/auth/app/api/auth_operations.py
// ===================================================================
async verifyEmail(
userId: string,
verificationToken: string
): Promise<{ message: string }> {
return apiClient.post<{ message: string }>(`${this.baseUrl}/verify-email`, {
user_id: userId,
verification_token: verificationToken,
});
}
// ===================================================================
// Account Management (self-service)
// Backend: services/auth/app/api/account_deletion.py
// ===================================================================
async deleteAccount(confirmEmail: string, password: string, reason?: string): Promise<{ message: string; deletion_date: string }> {
return apiClient.delete(`${this.baseUrl}/me/account`, {
data: {
confirm_email: confirmEmail,
password: password,
reason: reason || ''
}
});
}
async getAccountDeletionInfo(): Promise<Record<string, unknown>> {
return apiClient.get(`${this.baseUrl}/me/account/deletion-info`);
}
// ===================================================================
// GDPR Consent Management
// Backend: services/auth/app/api/consent.py
// ===================================================================
async recordConsent(consentData: {
terms_accepted: boolean;
privacy_accepted: boolean;
marketing_consent?: boolean;
analytics_consent?: boolean;
consent_method: string;
consent_version?: string;
}): Promise<Record<string, unknown>> {
return apiClient.post(`${this.baseUrl}/me/consent`, consentData);
}
async getCurrentConsent(): Promise<Record<string, unknown>> {
return apiClient.get(`${this.baseUrl}/me/consent/current`);
}
async getConsentHistory(): Promise<Record<string, unknown>[]> {
return apiClient.get(`${this.baseUrl}/me/consent/history`);
}
async updateConsent(consentData: {
terms_accepted: boolean;
privacy_accepted: boolean;
marketing_consent?: boolean;
analytics_consent?: boolean;
consent_method: string;
consent_version?: string;
}): Promise<Record<string, unknown>> {
return apiClient.put(`${this.baseUrl}/me/consent`, consentData);
}
async withdrawConsent(): Promise<{ message: string; withdrawn_count: number }> {
return apiClient.post(`${this.baseUrl}/me/consent/withdraw`);
}
// ===================================================================
// Data Export (GDPR)
// Backend: services/auth/app/api/data_export.py
// ===================================================================
async exportMyData(): Promise<Record<string, unknown>> {
return apiClient.get(`${this.baseUrl}/me/export`);
}
async getExportSummary(): Promise<Record<string, unknown>> {
return apiClient.get(`${this.baseUrl}/me/export/summary`);
}
// ===================================================================
// Onboarding Progress
// Backend: services/auth/app/api/onboarding_progress.py
// ===================================================================
async getOnboardingProgress(): Promise<Record<string, unknown>> {
return apiClient.get(`${this.baseUrl}/me/onboarding/progress`);
}
async updateOnboardingStep(stepName: string, completed: boolean, data?: Record<string, unknown>): Promise<Record<string, unknown>> {
return apiClient.put(`${this.baseUrl}/me/onboarding/step`, {
step_name: stepName,
completed: completed,
data: data
});
}
async getNextOnboardingStep(): Promise<{ step: string }> {
return apiClient.get(`${this.baseUrl}/me/onboarding/next-step`);
}
async canAccessOnboardingStep(stepName: string): Promise<{ can_access: boolean }> {
return apiClient.get(`${this.baseUrl}/me/onboarding/can-access/${stepName}`);
}
async completeOnboarding(): Promise<{ success: boolean; message: string }> {
return apiClient.post(`${this.baseUrl}/me/onboarding/complete`);
}
// ===================================================================
// Health Check
// ===================================================================
async healthCheck(): Promise<AuthHealthResponse> {
return apiClient.get<AuthHealthResponse>(`${this.baseUrl}/health`);
}
}
export const authService = new AuthService();

View File

@@ -0,0 +1,88 @@
// ================================================================
// frontend/src/api/services/consent.ts
// ================================================================
/**
* Consent Service - GDPR Compliance
*
* Backend API: services/auth/app/api/consent.py
*
* Last Updated: 2025-10-16
*/
import { apiClient } from '../client';
export interface ConsentRequest {
terms_accepted: boolean;
privacy_accepted: boolean;
marketing_consent?: boolean;
analytics_consent?: boolean;
consent_method: 'registration' | 'settings' | 'cookie_banner';
consent_version?: string;
}
export interface ConsentResponse {
id: string;
user_id: string;
terms_accepted: boolean;
privacy_accepted: boolean;
marketing_consent: boolean;
analytics_consent: boolean;
consent_version: string;
consent_method: string;
consented_at: string;
withdrawn_at: string | null;
}
export interface ConsentHistoryResponse {
id: string;
user_id: string;
action: string;
consent_snapshot: Record<string, any>;
created_at: string;
}
export class ConsentService {
private readonly baseUrl = '/auth';
/**
* Record user consent for data processing
* GDPR Article 7 - Conditions for consent
*/
async recordConsent(consentData: ConsentRequest): Promise<ConsentResponse> {
return apiClient.post<ConsentResponse>(`${this.baseUrl}/consent`, consentData);
}
/**
* Get current active consent for user
*/
async getCurrentConsent(): Promise<ConsentResponse | null> {
return apiClient.get<ConsentResponse | null>(`${this.baseUrl}/consent/current`);
}
/**
* Get complete consent history for user
* GDPR Article 7(1) - Demonstrating consent
*/
async getConsentHistory(): Promise<ConsentHistoryResponse[]> {
return apiClient.get<ConsentHistoryResponse[]>(`${this.baseUrl}/consent/history`);
}
/**
* Update user consent preferences
* GDPR Article 7(3) - Withdrawal of consent
*/
async updateConsent(consentData: ConsentRequest): Promise<ConsentResponse> {
return apiClient.put<ConsentResponse>(`${this.baseUrl}/consent`, consentData);
}
/**
* Withdraw all consent
* GDPR Article 7(3) - Right to withdraw consent
*/
async withdrawConsent(): Promise<{ message: string; withdrawn_count: number }> {
return apiClient.post<{ message: string; withdrawn_count: number }>(
`${this.baseUrl}/consent/withdraw`
);
}
}
export const consentService = new ConsentService();

View File

@@ -0,0 +1,204 @@
// ================================================================
// frontend/src/api/services/demo.ts
// ================================================================
/**
* Demo Session Service - Complete backend alignment
*
* Backend API structure (3-tier architecture):
* - ATOMIC: demo_accounts.py, demo_sessions.py
* - OPERATIONS: demo_operations.py
*
* Note: Demo service does NOT use tenant prefix
*
* Last Updated: 2025-10-05
* Status: ✅ Complete - Zero drift with backend
*/
import { apiClient } from '../client';
import type { DemoSessionResponse } from '../types/demo';
export interface DemoAccount {
account_type: string;
email: string;
name: string;
password: string;
description?: string;
features?: string[];
business_model?: string;
}
// Use the complete type from types/demo.ts which matches backend response
export type DemoSession = DemoSessionResponse;
export interface CreateSessionRequest {
demo_account_type: 'individual_bakery' | 'central_baker';
}
export interface ExtendSessionRequest {
session_id: string;
}
export interface DestroySessionRequest {
session_id: string;
}
export interface ServiceProgress {
status: 'not_started' | 'in_progress' | 'completed' | 'failed';
records_cloned: number;
error?: string;
}
export interface SessionStatusResponse {
session_id: string;
status: 'pending' | 'ready' | 'partial' | 'failed' | 'active' | 'expired' | 'destroyed';
total_records_cloned: number;
progress?: Record<string, ServiceProgress>;
errors?: Array<{ service: string; error_message: string }>;
}
// ===================================================================
// OPERATIONS: Demo Session Status and Cloning
// ===================================================================
/**
* Get session status
* GET /demo/sessions/{session_id}/status
*/
export const getSessionStatus = async (sessionId: string): Promise<SessionStatusResponse> => {
return await apiClient.get<SessionStatusResponse>(`/demo/sessions/${sessionId}/status`);
};
/**
* Retry data cloning for a session
* POST /demo/sessions/{session_id}/retry
*/
export const retryCloning = async (sessionId: string): Promise<SessionStatusResponse> => {
return await apiClient.post<SessionStatusResponse>(`/demo/sessions/${sessionId}/retry`, {});
};
// ===================================================================
// ATOMIC: Demo Accounts
// Backend: services/demo_session/app/api/demo_accounts.py
// ===================================================================
/**
* Get available demo accounts
* GET /demo/accounts
*/
export const getDemoAccounts = async (): Promise<DemoAccount[]> => {
return await apiClient.get<DemoAccount[]>('/demo/accounts');
};
// ===================================================================
// ATOMIC: Demo Sessions
// Backend: services/demo_session/app/api/demo_sessions.py
// ===================================================================
/**
* Create a new demo session
* POST /demo/sessions
*/
export const createDemoSession = async (
request: CreateSessionRequest
): Promise<DemoSession> => {
return await apiClient.post<DemoSession>('/demo/sessions', request);
};
/**
* Get demo session details
* GET /demo/sessions/{session_id}
*/
export const getDemoSession = async (sessionId: string): Promise<any> => {
return await apiClient.get(`/demo/sessions/${sessionId}`);
};
// ===================================================================
// OPERATIONS: Demo Session Management
// Backend: services/demo_session/app/api/demo_operations.py
// ===================================================================
/**
* Extend an existing demo session
* POST /demo/sessions/{session_id}/extend
*/
export const extendDemoSession = async (
request: ExtendSessionRequest
): Promise<DemoSession> => {
return await apiClient.post<DemoSession>(
`/demo/sessions/${request.session_id}/extend`,
{}
);
};
/**
* Destroy a demo session
* Note: This might be a DELETE endpoint - verify backend implementation
*/
export const destroyDemoSession = async (
request: DestroySessionRequest
): Promise<{ message: string }> => {
return await apiClient.post<{ message: string }>(
`/demo/sessions/${request.session_id}/destroy`,
{}
);
};
/**
* Get demo session statistics
* GET /demo/stats
*/
export const getDemoStats = async (): Promise<any> => {
return await apiClient.get('/demo/stats');
};
/**
* Cleanup expired demo sessions (Admin/Operations)
* POST /demo/operations/cleanup
*/
export const cleanupExpiredSessions = async (): Promise<any> => {
return await apiClient.post('/demo/operations/cleanup', {});
};
// ===================================================================
// API Service Class
// ===================================================================
export class DemoSessionAPI {
async getDemoAccounts(): Promise<DemoAccount[]> {
return getDemoAccounts();
}
async createDemoSession(request: CreateSessionRequest): Promise<DemoSession> {
return createDemoSession(request);
}
async getDemoSession(sessionId: string): Promise<any> {
return getDemoSession(sessionId);
}
async extendDemoSession(request: ExtendSessionRequest): Promise<DemoSession> {
return extendDemoSession(request);
}
async destroyDemoSession(request: DestroySessionRequest): Promise<{ message: string }> {
return destroyDemoSession(request);
}
async getDemoStats(): Promise<any> {
return getDemoStats();
}
async cleanupExpiredSessions(): Promise<any> {
return cleanupExpiredSessions();
}
async getSessionStatus(sessionId: string): Promise<SessionStatusResponse> {
return getSessionStatus(sessionId);
}
async retryCloning(sessionId: string): Promise<SessionStatusResponse> {
return retryCloning(sessionId);
}
}
export const demoSessionAPI = new DemoSessionAPI();

View File

@@ -0,0 +1,68 @@
// ================================================================
// frontend/src/api/services/distribution.ts
// ================================================================
/**
* Distribution Service - Complete backend alignment
*
* Backend API structure:
* - services/distribution/app/api/routes.py
* - services/distribution/app/api/shipments.py
*
* Last Updated: 2025-12-03
* Status: ✅ Complete - Backend alignment
*/
import { apiClient } from '../client';
export class DistributionService {
private readonly baseUrl = '/tenants';
// ===================================================================
// SHIPMENTS
// Backend: services/distribution/app/api/shipments.py
// ===================================================================
async getShipments(
tenantId: string,
date?: string
): Promise<any[]> {
const params = new URLSearchParams();
if (date) {
params.append('date_from', date);
params.append('date_to', date);
}
const queryString = params.toString();
const url = `${this.baseUrl}/${tenantId}/distribution/shipments${queryString ? `?${queryString}` : ''}`;
const response = await apiClient.get<any>(url);
return response.shipments || response;
}
async getShipment(
tenantId: string,
shipmentId: string
): Promise<any> {
return apiClient.get(`${this.baseUrl}/${tenantId}/distribution/shipments/${shipmentId}`);
}
async getRouteSequences(
tenantId: string,
date?: string
): Promise<any[]> {
const params = new URLSearchParams();
if (date) {
params.append('date_from', date);
params.append('date_to', date);
}
const queryString = params.toString();
const url = `${this.baseUrl}/${tenantId}/distribution/routes${queryString ? `?${queryString}` : ''}`;
const response = await apiClient.get<any>(url);
return response.routes || response;
}
}
export const distributionService = new DistributionService();
export default distributionService;

View File

@@ -0,0 +1,281 @@
// frontend/src/api/services/equipment.ts
/**
* Equipment API service
*/
import { apiClient } from '../client';
import type {
Equipment,
EquipmentCreate,
EquipmentUpdate,
EquipmentResponse,
EquipmentListResponse,
EquipmentDeletionSummary
} from '../types/equipment';
class EquipmentService {
private readonly baseURL = '/tenants';
/**
* Helper to convert snake_case API response to camelCase Equipment
*/
private convertToEquipment(response: EquipmentResponse): Equipment {
return {
id: response.id,
tenant_id: response.tenant_id,
name: response.name,
type: response.type,
model: response.model || '',
serialNumber: response.serial_number || '',
location: response.location || '',
status: response.status.toLowerCase() as Equipment['status'],
installDate: response.install_date || new Date().toISOString().split('T')[0],
lastMaintenance: response.last_maintenance_date || new Date().toISOString().split('T')[0],
nextMaintenance: response.next_maintenance_date || new Date().toISOString().split('T')[0],
maintenanceInterval: response.maintenance_interval_days || 30,
temperature: response.current_temperature || undefined,
targetTemperature: response.target_temperature || undefined,
efficiency: response.efficiency_percentage || 0,
uptime: response.uptime_percentage || 0,
energyUsage: response.energy_usage_kwh || 0,
utilizationToday: 0, // Not in backend yet
alerts: [], // Not in backend yet
maintenanceHistory: [], // Not in backend yet
specifications: {
power: response.power_kw || 0,
capacity: response.capacity || 0,
dimensions: {
width: 0, // Not in backend separately
height: 0,
depth: 0
},
weight: response.weight_kg || 0
},
is_active: response.is_active,
support_contact: response.support_contact || undefined,
created_at: response.created_at,
updated_at: response.updated_at
};
}
/**
* Helper to convert Equipment to API request format (snake_case)
*/
private convertToApiFormat(equipment: Partial<Equipment>): EquipmentCreate | EquipmentUpdate {
return {
name: equipment.name,
type: equipment.type,
model: equipment.model,
serial_number: equipment.serialNumber,
location: equipment.location,
status: equipment.status,
install_date: equipment.installDate,
last_maintenance_date: equipment.lastMaintenance,
next_maintenance_date: equipment.nextMaintenance,
maintenance_interval_days: equipment.maintenanceInterval,
efficiency_percentage: equipment.efficiency,
uptime_percentage: equipment.uptime,
energy_usage_kwh: equipment.energyUsage,
power_kw: equipment.specifications?.power,
capacity: equipment.specifications?.capacity,
weight_kg: equipment.specifications?.weight,
current_temperature: equipment.temperature,
target_temperature: equipment.targetTemperature,
is_active: equipment.is_active,
support_contact: equipment.support_contact
};
}
/**
* Get all equipment for a tenant
*/
async getEquipment(
tenantId: string,
filters?: {
status?: string;
type?: string;
is_active?: boolean;
}
): Promise<Equipment[]> {
const params = new URLSearchParams();
if (filters?.status) params.append('status', filters.status);
if (filters?.type) params.append('type', filters.type);
if (filters?.is_active !== undefined) params.append('is_active', String(filters.is_active));
const queryString = params.toString();
const url = `${this.baseURL}/${tenantId}/production/equipment${queryString ? `?${queryString}` : ''}`;
const data: EquipmentListResponse = await apiClient.get(url, {
headers: { 'X-Tenant-ID': tenantId }
});
return data.equipment.map(eq => this.convertToEquipment(eq));
}
/**
* Get a specific equipment item
*/
async getEquipmentById(
tenantId: string,
equipmentId: string
): Promise<Equipment> {
const data: EquipmentResponse = await apiClient.get(
`${this.baseURL}/${tenantId}/production/equipment/${equipmentId}`,
{
headers: { 'X-Tenant-ID': tenantId }
}
);
return this.convertToEquipment(data);
}
/**
* Create a new equipment item
*/
async createEquipment(
tenantId: string,
equipmentData: Equipment
): Promise<Equipment> {
const apiData = this.convertToApiFormat(equipmentData);
const data: EquipmentResponse = await apiClient.post(
`${this.baseURL}/${tenantId}/production/equipment`,
apiData,
{
headers: { 'X-Tenant-ID': tenantId }
}
);
return this.convertToEquipment(data);
}
/**
* Update an equipment item
*/
async updateEquipment(
tenantId: string,
equipmentId: string,
equipmentData: Partial<Equipment>
): Promise<Equipment> {
const apiData = this.convertToApiFormat(equipmentData);
const data: EquipmentResponse = await apiClient.put(
`${this.baseURL}/${tenantId}/production/equipment/${equipmentId}`,
apiData,
{
headers: { 'X-Tenant-ID': tenantId }
}
);
return this.convertToEquipment(data);
}
/**
* Delete an equipment item (soft delete)
*/
async deleteEquipment(tenantId: string, equipmentId: string): Promise<void> {
await apiClient.delete(
`${this.baseURL}/${tenantId}/production/equipment/${equipmentId}`,
{
headers: { 'X-Tenant-ID': tenantId }
}
);
}
/**
* Permanently delete an equipment item (hard delete)
*/
async hardDeleteEquipment(tenantId: string, equipmentId: string): Promise<void> {
await apiClient.delete(
`${this.baseURL}/${tenantId}/production/equipment/${equipmentId}?permanent=true`,
{
headers: { 'X-Tenant-ID': tenantId }
}
);
}
/**
* Get deletion summary for an equipment item
*/
async getEquipmentDeletionSummary(
tenantId: string,
equipmentId: string
): Promise<EquipmentDeletionSummary> {
const data: EquipmentDeletionSummary = await apiClient.get(
`${this.baseURL}/${tenantId}/production/equipment/${equipmentId}/deletion-summary`,
{
headers: { 'X-Tenant-ID': tenantId }
}
);
return data;
}
/**
* Report equipment failure
*/
async reportEquipmentFailure(
tenantId: string,
equipmentId: string,
failureData: {
failureType: string;
severity: string;
description: string;
photos?: File[];
estimatedImpact: boolean;
}
): Promise<Equipment> {
const apiData = {
failureType: failureData.failureType,
severity: failureData.severity,
description: failureData.description,
estimatedImpact: failureData.estimatedImpact,
// Note: Photos would be handled separately in a real implementation
// For now, we'll just send the metadata
photos: failureData.photos ? failureData.photos.map(p => p.name) : []
};
const data: EquipmentResponse = await apiClient.post(
`${this.baseURL}/${tenantId}/production/equipment/${equipmentId}/report-failure`,
apiData,
{
headers: { 'X-Tenant-ID': tenantId }
}
);
return this.convertToEquipment(data);
}
/**
* Mark equipment as repaired
*/
async markEquipmentAsRepaired(
tenantId: string,
equipmentId: string,
repairData: {
repairDate: string;
technicianName: string;
repairDescription: string;
partsReplaced: string[];
cost: number;
photos?: File[];
testResults: boolean;
}
): Promise<Equipment> {
const apiData = {
repairDate: repairData.repairDate,
technicianName: repairData.technicianName,
repairDescription: repairData.repairDescription,
partsReplaced: repairData.partsReplaced,
cost: repairData.cost,
testResults: repairData.testResults,
// Note: Photos would be handled separately in a real implementation
// For now, we'll just send the metadata
photos: repairData.photos ? repairData.photos.map(p => p.name) : []
};
const data: EquipmentResponse = await apiClient.post(
`${this.baseURL}/${tenantId}/production/equipment/${equipmentId}/mark-repaired`,
apiData,
{
headers: { 'X-Tenant-ID': tenantId }
}
);
return this.convertToEquipment(data);
}
}
export const equipmentService = new EquipmentService();

View File

@@ -0,0 +1,123 @@
// frontend/src/api/services/external.ts
/**
* External Data API Service
* Handles weather and traffic data operations
*/
import { apiClient } from '../client';
import type {
CityInfoResponse,
DataAvailabilityResponse,
WeatherDataResponse,
TrafficDataResponse,
HistoricalWeatherRequest,
HistoricalTrafficRequest,
} from '../types/external';
class ExternalDataService {
/**
* List all supported cities
*/
async listCities(): Promise<CityInfoResponse[]> {
return await apiClient.get<CityInfoResponse[]>(
'/api/v1/external/cities'
);
}
/**
* Get data availability for a specific city
*/
async getCityAvailability(cityId: string): Promise<DataAvailabilityResponse> {
return await apiClient.get<DataAvailabilityResponse>(
`/api/v1/external/operations/cities/${cityId}/availability`
);
}
/**
* Get historical weather data (optimized city-based endpoint)
*/
async getHistoricalWeatherOptimized(
tenantId: string,
params: {
latitude: number;
longitude: number;
start_date: string;
end_date: string;
}
): Promise<WeatherDataResponse[]> {
return await apiClient.get<WeatherDataResponse[]>(
`/api/v1/tenants/${tenantId}/external/operations/historical-weather-optimized`,
{ params }
);
}
/**
* Get historical traffic data (optimized city-based endpoint)
*/
async getHistoricalTrafficOptimized(
tenantId: string,
params: {
latitude: number;
longitude: number;
start_date: string;
end_date: string;
}
): Promise<TrafficDataResponse[]> {
return await apiClient.get<TrafficDataResponse[]>(
`/api/v1/tenants/${tenantId}/external/operations/historical-traffic-optimized`,
{ params }
);
}
/**
* Get current weather for a location (real-time)
*/
async getCurrentWeather(
tenantId: string,
params: {
latitude: number;
longitude: number;
}
): Promise<WeatherDataResponse> {
return await apiClient.get<WeatherDataResponse>(
`/api/v1/tenants/${tenantId}/external/operations/weather/current`,
{ params }
);
}
/**
* Get weather forecast
*/
async getWeatherForecast(
tenantId: string,
params: {
latitude: number;
longitude: number;
days?: number;
}
): Promise<WeatherDataResponse[]> {
return await apiClient.get<WeatherDataResponse[]>(
`/api/v1/tenants/${tenantId}/external/operations/weather/forecast`,
{ params }
);
}
/**
* Get current traffic conditions (real-time)
*/
async getCurrentTraffic(
tenantId: string,
params: {
latitude: number;
longitude: number;
}
): Promise<TrafficDataResponse> {
return await apiClient.get<TrafficDataResponse>(
`/api/v1/tenants/${tenantId}/external/operations/traffic/current`,
{ params }
);
}
}
export const externalDataService = new ExternalDataService();
export default externalDataService;

View File

@@ -0,0 +1,317 @@
// ================================================================
// frontend/src/api/services/forecasting.ts
// ================================================================
/**
* Forecasting Service - Complete backend alignment
*
* Backend API structure (3-tier architecture):
* - ATOMIC: forecasts.py
* - OPERATIONS: forecasting_operations.py
* - ANALYTICS: analytics.py
*
* Last Updated: 2025-10-05
* Status: ✅ Complete - Zero drift with backend
*/
import { apiClient } from '../client/apiClient';
import {
ForecastRequest,
ForecastResponse,
BatchForecastRequest,
BatchForecastResponse,
ForecastListResponse,
ForecastByIdResponse,
ForecastStatistics,
DeleteForecastResponse,
GetForecastsParams,
ForecastingHealthResponse,
MultiDayForecastResponse,
ScenarioSimulationRequest,
ScenarioSimulationResponse,
ScenarioComparisonRequest,
ScenarioComparisonResponse,
} from '../types/forecasting';
export class ForecastingService {
private readonly baseUrl = '/tenants';
// ===================================================================
// ATOMIC: Forecast CRUD
// Backend: services/forecasting/app/api/forecasts.py
// ===================================================================
/**
* List forecasts with optional filters
* GET /tenants/{tenant_id}/forecasting/forecasts
*/
async getTenantForecasts(
tenantId: string,
params?: GetForecastsParams
): Promise<ForecastListResponse> {
const searchParams = new URLSearchParams();
if (params?.inventory_product_id) {
searchParams.append('inventory_product_id', params.inventory_product_id);
}
if (params?.start_date) {
searchParams.append('start_date', params.start_date);
}
if (params?.end_date) {
searchParams.append('end_date', params.end_date);
}
if (params?.skip !== undefined) {
searchParams.append('skip', params.skip.toString());
}
if (params?.limit !== undefined) {
searchParams.append('limit', params.limit.toString());
}
const queryString = searchParams.toString();
const url = `${this.baseUrl}/${tenantId}/forecasting/forecasts${queryString ? `?${queryString}` : ''}`;
return apiClient.get<ForecastListResponse>(url);
}
/**
* Get specific forecast by ID
* GET /tenants/{tenant_id}/forecasting/forecasts/{forecast_id}
*/
async getForecastById(
tenantId: string,
forecastId: string
): Promise<ForecastByIdResponse> {
return apiClient.get<ForecastByIdResponse>(
`${this.baseUrl}/${tenantId}/forecasting/forecasts/${forecastId}`
);
}
/**
* Delete a forecast
* DELETE /tenants/{tenant_id}/forecasting/forecasts/{forecast_id}
*/
async deleteForecast(
tenantId: string,
forecastId: string
): Promise<DeleteForecastResponse> {
return apiClient.delete<DeleteForecastResponse>(
`${this.baseUrl}/${tenantId}/forecasting/forecasts/${forecastId}`
);
}
// ===================================================================
// OPERATIONS: Forecasting Operations
// Backend: services/forecasting/app/api/forecasting_operations.py
// ===================================================================
/**
* Generate a single product forecast
* POST /tenants/{tenant_id}/forecasting/operations/single
*/
async createSingleForecast(
tenantId: string,
request: ForecastRequest
): Promise<ForecastResponse> {
return apiClient.post<ForecastResponse, ForecastRequest>(
`${this.baseUrl}/${tenantId}/forecasting/operations/single`,
request
);
}
/**
* Generate multiple daily forecasts for the specified period
* POST /tenants/{tenant_id}/forecasting/operations/multi-day
*/
async createMultiDayForecast(
tenantId: string,
request: ForecastRequest
): Promise<MultiDayForecastResponse> {
return apiClient.post<MultiDayForecastResponse, ForecastRequest>(
`${this.baseUrl}/${tenantId}/forecasting/operations/multi-day`,
request
);
}
/**
* Generate batch forecasts for multiple products
* POST /tenants/{tenant_id}/forecasting/operations/batch
*/
async createBatchForecast(
tenantId: string,
request: BatchForecastRequest
): Promise<BatchForecastResponse> {
return apiClient.post<BatchForecastResponse, BatchForecastRequest>(
`${this.baseUrl}/${tenantId}/forecasting/operations/batch`,
request
);
}
/**
* Get comprehensive forecast statistics
* GET /tenants/{tenant_id}/forecasting/operations/statistics
*/
async getForecastStatistics(
tenantId: string
): Promise<ForecastStatistics> {
return apiClient.get<ForecastStatistics>(
`${this.baseUrl}/${tenantId}/forecasting/operations/statistics`
);
}
/**
* Generate real-time prediction
* POST /tenants/{tenant_id}/forecasting/operations/realtime
*/
async generateRealtimePrediction(
tenantId: string,
predictionRequest: {
inventory_product_id: string;
model_id: string;
features: Record<string, any>;
model_path?: string;
confidence_level?: number;
}
): Promise<{
tenant_id: string;
inventory_product_id: string;
model_id: string;
prediction: number;
confidence: number;
timestamp: string;
}> {
return apiClient.post(
`${this.baseUrl}/${tenantId}/forecasting/operations/realtime`,
predictionRequest
);
}
/**
* Generate batch predictions
* POST /tenants/{tenant_id}/forecasting/operations/batch-predictions
*/
async generateBatchPredictions(
tenantId: string,
predictionsRequest: Array<{
inventory_product_id?: string;
model_id: string;
features: Record<string, any>;
model_path?: string;
confidence_level?: number;
}>
): Promise<{
predictions: Array<{
inventory_product_id?: string;
prediction?: number;
confidence?: number;
success: boolean;
error?: string;
}>;
total: number;
}> {
return apiClient.post(
`${this.baseUrl}/${tenantId}/forecasting/operations/batch-predictions`,
predictionsRequest
);
}
/**
* Validate predictions against actual sales data
* POST /tenants/{tenant_id}/forecasting/operations/validate-predictions
*/
async validatePredictions(
tenantId: string,
startDate: string,
endDate: string
): Promise<any> {
return apiClient.post(
`${this.baseUrl}/${tenantId}/forecasting/operations/validate-predictions?start_date=${startDate}&end_date=${endDate}`,
{}
);
}
/**
* Clear prediction cache
* DELETE /tenants/{tenant_id}/forecasting/operations/cache
*/
async clearPredictionCache(tenantId: string): Promise<{ message: string }> {
return apiClient.delete(
`${this.baseUrl}/${tenantId}/forecasting/operations/cache`
);
}
// ===================================================================
// ANALYTICS: Performance Metrics
// Backend: services/forecasting/app/api/analytics.py
// ===================================================================
/**
* Get predictions performance analytics
* GET /tenants/{tenant_id}/forecasting/analytics/predictions-performance
*/
async getPredictionsPerformance(
tenantId: string,
startDate?: string,
endDate?: string
): Promise<any> {
const searchParams = new URLSearchParams();
if (startDate) searchParams.append('start_date', startDate);
if (endDate) searchParams.append('end_date', endDate);
const queryString = searchParams.toString();
return apiClient.get(
`${this.baseUrl}/${tenantId}/forecasting/analytics/predictions-performance${queryString ? `?${queryString}` : ''}`
);
}
// ===================================================================
// SCENARIO SIMULATION - PROFESSIONAL/ENTERPRISE ONLY
// Backend: services/forecasting/app/api/scenario_operations.py
// ===================================================================
/**
* Run a "what-if" scenario simulation on forecasts
* POST /tenants/{tenant_id}/forecasting/analytics/scenario-simulation
*
* **PROFESSIONAL/ENTERPRISE ONLY**
*/
async simulateScenario(
tenantId: string,
request: ScenarioSimulationRequest
): Promise<ScenarioSimulationResponse> {
return apiClient.post<ScenarioSimulationResponse, ScenarioSimulationRequest>(
`${this.baseUrl}/${tenantId}/forecasting/analytics/scenario-simulation`,
request
);
}
/**
* Compare multiple scenario simulations
* POST /tenants/{tenant_id}/forecasting/analytics/scenario-comparison
*
* **PROFESSIONAL/ENTERPRISE ONLY**
*/
async compareScenarios(
tenantId: string,
request: ScenarioComparisonRequest
): Promise<ScenarioComparisonResponse> {
return apiClient.post<ScenarioComparisonResponse, ScenarioComparisonRequest>(
`${this.baseUrl}/${tenantId}/forecasting/analytics/scenario-comparison`,
request
);
}
// ===================================================================
// Health Check
// ===================================================================
/**
* Health check for forecasting service
* GET /health
*/
async getHealthCheck(): Promise<ForecastingHealthResponse> {
return apiClient.get<ForecastingHealthResponse>('/health');
}
}
// Export singleton instance
export const forecastingService = new ForecastingService();
export default forecastingService;

View File

@@ -0,0 +1,544 @@
// ================================================================
// frontend/src/api/services/inventory.ts
// ================================================================
/**
* Inventory Service - Complete backend alignment
*
* Backend API structure (3-tier architecture):
* - ATOMIC: ingredients.py, stock_entries.py, transformations.py, temperature_logs.py
* - OPERATIONS: inventory_operations.py, food_safety_operations.py
* - ANALYTICS: analytics.py, dashboard.py
* - COMPLIANCE: food_safety_alerts.py, food_safety_compliance.py
*
* Last Updated: 2025-10-05
* Status: ✅ Complete - Zero drift with backend
*/
import { apiClient } from '../client';
import {
// Ingredients
IngredientCreate,
IngredientUpdate,
IngredientResponse,
IngredientFilter,
BulkIngredientResponse,
// Stock
StockCreate,
StockUpdate,
StockResponse,
StockFilter,
StockMovementCreate,
StockMovementResponse,
BulkStockResponse,
// Operations
StockConsumptionRequest,
StockConsumptionResponse,
// Transformations
ProductTransformationCreate,
ProductTransformationResponse,
// Food Safety
TemperatureLogCreate,
TemperatureLogResponse,
FoodSafetyAlertResponse,
FoodSafetyComplianceResponse,
// Classification
ProductClassificationRequest,
ProductSuggestionResponse,
BatchClassificationRequest,
BatchClassificationResponse,
BusinessModelAnalysisResponse,
// Dashboard & Analytics
InventorySummary,
InventoryDashboardSummary,
InventoryAnalytics,
// Common
PaginatedResponse,
DeletionSummary,
} from '../types/inventory';
export class InventoryService {
private readonly baseUrl = '/tenants';
// ===================================================================
// ATOMIC: Ingredients CRUD
// Backend: services/inventory/app/api/ingredients.py
// ===================================================================
async createIngredient(
tenantId: string,
ingredientData: IngredientCreate
): Promise<IngredientResponse> {
return apiClient.post<IngredientResponse>(
`${this.baseUrl}/${tenantId}/inventory/ingredients`,
ingredientData
);
}
async bulkCreateIngredients(
tenantId: string,
ingredients: IngredientCreate[]
): Promise<BulkIngredientResponse> {
return apiClient.post<BulkIngredientResponse>(
`${this.baseUrl}/${tenantId}/inventory/ingredients/bulk`,
{ ingredients }
);
}
async getIngredient(tenantId: string, ingredientId: string): Promise<IngredientResponse> {
return apiClient.get<IngredientResponse>(
`${this.baseUrl}/${tenantId}/inventory/ingredients/${ingredientId}`
);
}
async getIngredients(
tenantId: string,
filter?: IngredientFilter
): Promise<IngredientResponse[]> {
const queryParams = new URLSearchParams();
if (filter?.category) queryParams.append('category', filter.category);
if (filter?.stock_status) queryParams.append('stock_status', filter.stock_status);
if (filter?.requires_refrigeration !== undefined)
queryParams.append('requires_refrigeration', filter.requires_refrigeration.toString());
if (filter?.requires_freezing !== undefined)
queryParams.append('requires_freezing', filter.requires_freezing.toString());
if (filter?.is_seasonal !== undefined)
queryParams.append('is_seasonal', filter.is_seasonal.toString());
if (filter?.supplier_id) queryParams.append('supplier_id', filter.supplier_id);
if (filter?.expiring_within_days !== undefined)
queryParams.append('expiring_within_days', filter.expiring_within_days.toString());
if (filter?.search) queryParams.append('search', filter.search);
if (filter?.limit !== undefined) queryParams.append('limit', filter.limit.toString());
if (filter?.offset !== undefined) queryParams.append('offset', filter.offset.toString());
if (filter?.order_by) queryParams.append('order_by', filter.order_by);
if (filter?.order_direction) queryParams.append('order_direction', filter.order_direction);
const url = queryParams.toString()
? `${this.baseUrl}/${tenantId}/inventory/ingredients?${queryParams.toString()}`
: `${this.baseUrl}/${tenantId}/inventory/ingredients`;
return apiClient.get<IngredientResponse[]>(url);
}
async updateIngredient(
tenantId: string,
ingredientId: string,
updateData: IngredientUpdate
): Promise<IngredientResponse> {
return apiClient.put<IngredientResponse>(
`${this.baseUrl}/${tenantId}/inventory/ingredients/${ingredientId}`,
updateData
);
}
async softDeleteIngredient(tenantId: string, ingredientId: string): Promise<void> {
return apiClient.delete<void>(
`${this.baseUrl}/${tenantId}/inventory/ingredients/${ingredientId}`
);
}
async hardDeleteIngredient(tenantId: string, ingredientId: string): Promise<DeletionSummary> {
return apiClient.delete<DeletionSummary>(
`${this.baseUrl}/${tenantId}/inventory/ingredients/${ingredientId}/hard`
);
}
async getIngredientsByCategory(
tenantId: string
): Promise<Record<string, IngredientResponse[]>> {
return apiClient.get<Record<string, IngredientResponse[]>>(
`${this.baseUrl}/${tenantId}/inventory/ingredients/by-category`
);
}
// ===================================================================
// ATOMIC: Stock CRUD
// Backend: services/inventory/app/api/stock_entries.py
// ===================================================================
async addStock(tenantId: string, stockData: StockCreate): Promise<StockResponse> {
return apiClient.post<StockResponse>(
`${this.baseUrl}/${tenantId}/inventory/stock`,
stockData
);
}
async bulkAddStock(
tenantId: string,
stocks: StockCreate[]
): Promise<BulkStockResponse> {
return apiClient.post<BulkStockResponse>(
`${this.baseUrl}/${tenantId}/inventory/stock/bulk`,
{ stocks }
);
}
async getStock(tenantId: string, stockId: string): Promise<StockResponse> {
return apiClient.get<StockResponse>(
`${this.baseUrl}/${tenantId}/inventory/stock/${stockId}`
);
}
async getStockByIngredient(
tenantId: string,
ingredientId: string,
includeUnavailable: boolean = false
): Promise<StockResponse[]> {
const queryParams = new URLSearchParams();
queryParams.append('include_unavailable', includeUnavailable.toString());
return apiClient.get<StockResponse[]>(
`${this.baseUrl}/${tenantId}/inventory/ingredients/${ingredientId}/stock?${queryParams.toString()}`
);
}
async getAllStock(
tenantId: string,
filter?: StockFilter
): Promise<PaginatedResponse<StockResponse>> {
const queryParams = new URLSearchParams();
if (filter?.ingredient_id) queryParams.append('ingredient_id', filter.ingredient_id);
if (filter?.is_available !== undefined)
queryParams.append('is_available', filter.is_available.toString());
if (filter?.is_expired !== undefined)
queryParams.append('is_expired', filter.is_expired.toString());
if (filter?.expiring_within_days !== undefined)
queryParams.append('expiring_within_days', filter.expiring_within_days.toString());
if (filter?.batch_number) queryParams.append('batch_number', filter.batch_number);
if (filter?.supplier_id) queryParams.append('supplier_id', filter.supplier_id);
if (filter?.limit !== undefined) queryParams.append('limit', filter.limit.toString());
if (filter?.offset !== undefined) queryParams.append('offset', filter.offset.toString());
if (filter?.order_by) queryParams.append('order_by', filter.order_by);
if (filter?.order_direction) queryParams.append('order_direction', filter.order_direction);
const url = queryParams.toString()
? `${this.baseUrl}/${tenantId}/inventory/stock?${queryParams.toString()}`
: `${this.baseUrl}/${tenantId}/inventory/stock`;
return apiClient.get<PaginatedResponse<StockResponse>>(url);
}
async updateStock(
tenantId: string,
stockId: string,
updateData: StockUpdate
): Promise<StockResponse> {
return apiClient.put<StockResponse>(
`${this.baseUrl}/${tenantId}/inventory/stock/${stockId}`,
updateData
);
}
async deleteStock(tenantId: string, stockId: string): Promise<{ message: string }> {
return apiClient.delete<{ message: string }>(
`${this.baseUrl}/${tenantId}/inventory/stock/${stockId}`
);
}
// ===================================================================
// ATOMIC: Stock Movements
// Backend: services/inventory/app/api/stock_entries.py
// ===================================================================
async createStockMovement(
tenantId: string,
movementData: StockMovementCreate
): Promise<StockMovementResponse> {
return apiClient.post<StockMovementResponse>(
`${this.baseUrl}/${tenantId}/inventory/stock/movements`,
movementData
);
}
async getStockMovements(
tenantId: string,
ingredientId?: string,
limit: number = 50,
offset: number = 0
): Promise<StockMovementResponse[]> {
const queryParams = new URLSearchParams();
if (ingredientId) queryParams.append('ingredient_id', ingredientId);
queryParams.append('limit', limit.toString());
queryParams.append('skip', offset.toString());
return apiClient.get<StockMovementResponse[]>(
`${this.baseUrl}/${tenantId}/inventory/stock/movements?${queryParams.toString()}`
);
}
// ===================================================================
// ATOMIC: Transformations
// Backend: services/inventory/app/api/transformations.py
// ===================================================================
async createTransformation(
tenantId: string,
transformationData: ProductTransformationCreate
): Promise<ProductTransformationResponse> {
return apiClient.post<ProductTransformationResponse>(
`${this.baseUrl}/${tenantId}/inventory/transformations`,
transformationData
);
}
async listTransformations(
tenantId: string,
limit: number = 50,
offset: number = 0
): Promise<ProductTransformationResponse[]> {
const queryParams = new URLSearchParams();
queryParams.append('limit', limit.toString());
queryParams.append('skip', offset.toString());
return apiClient.get<ProductTransformationResponse[]>(
`${this.baseUrl}/${tenantId}/inventory/transformations?${queryParams.toString()}`
);
}
// ===================================================================
// ATOMIC: Temperature Logs
// Backend: services/inventory/app/api/temperature_logs.py
// ===================================================================
async logTemperature(
tenantId: string,
temperatureData: TemperatureLogCreate
): Promise<TemperatureLogResponse> {
return apiClient.post<TemperatureLogResponse>(
`${this.baseUrl}/${tenantId}/inventory/temperature-logs`,
temperatureData
);
}
async listTemperatureLogs(
tenantId: string,
ingredientId?: string,
startDate?: string,
endDate?: string,
limit: number = 100,
offset: number = 0
): Promise<TemperatureLogResponse[]> {
const queryParams = new URLSearchParams();
if (ingredientId) queryParams.append('ingredient_id', ingredientId);
if (startDate) queryParams.append('start_date', startDate);
if (endDate) queryParams.append('end_date', endDate);
queryParams.append('limit', limit.toString());
queryParams.append('skip', offset.toString());
return apiClient.get<TemperatureLogResponse[]>(
`${this.baseUrl}/${tenantId}/inventory/temperature-logs?${queryParams.toString()}`
);
}
// ===================================================================
// OPERATIONS: Stock Management
// Backend: services/inventory/app/api/inventory_operations.py
// ===================================================================
async consumeStock(
tenantId: string,
consumptionData: StockConsumptionRequest
): Promise<StockConsumptionResponse> {
const queryParams = new URLSearchParams();
queryParams.append('ingredient_id', consumptionData.ingredient_id);
queryParams.append('quantity', consumptionData.quantity.toString());
if (consumptionData.reference_number)
queryParams.append('reference_number', consumptionData.reference_number);
if (consumptionData.notes) queryParams.append('notes', consumptionData.notes);
if (consumptionData.fifo !== undefined)
queryParams.append('fifo', consumptionData.fifo.toString());
return apiClient.post<StockConsumptionResponse>(
`${this.baseUrl}/${tenantId}/inventory/operations/consume-stock?${queryParams.toString()}`
);
}
async getExpiringStock(
tenantId: string,
withinDays: number = 7
): Promise<StockResponse[]> {
const queryParams = new URLSearchParams();
queryParams.append('days_ahead', withinDays.toString());
return apiClient.get<StockResponse[]>(
`${this.baseUrl}/${tenantId}/inventory/operations/stock/expiring?${queryParams.toString()}`
);
}
async getExpiredStock(tenantId: string): Promise<StockResponse[]> {
return apiClient.get<StockResponse[]>(
`${this.baseUrl}/${tenantId}/inventory/operations/stock/expired`
);
}
async getLowStockIngredients(tenantId: string): Promise<IngredientResponse[]> {
return apiClient.get<IngredientResponse[]>(
`${this.baseUrl}/${tenantId}/inventory/operations/stock/low-stock`
);
}
async getStockSummary(tenantId: string): Promise<InventorySummary> {
return apiClient.get<InventorySummary>(
`${this.baseUrl}/${tenantId}/inventory/operations/stock/summary`
);
}
// ===================================================================
// OPERATIONS: Classification
// Backend: services/inventory/app/api/inventory_operations.py
// ===================================================================
async classifyProduct(
tenantId: string,
classificationData: ProductClassificationRequest
): Promise<ProductSuggestionResponse> {
return apiClient.post<ProductSuggestionResponse>(
`${this.baseUrl}/${tenantId}/inventory/operations/classify`,
classificationData
);
}
async classifyBatch(
tenantId: string,
batchData: BatchClassificationRequest
): Promise<BatchClassificationResponse> {
return apiClient.post<BatchClassificationResponse>(
`${this.baseUrl}/${tenantId}/inventory/operations/classify-products-batch`,
batchData
);
}
async analyzeBusinessModel(tenantId: string): Promise<BusinessModelAnalysisResponse> {
return apiClient.post<BusinessModelAnalysisResponse>(
`${this.baseUrl}/${tenantId}/inventory/operations/analyze-business-model`
);
}
// ===================================================================
// OPERATIONS: Batch Inventory Summary (Enterprise Feature)
// Backend: services/inventory/app/api/inventory_operations.py
// ===================================================================
async getBatchInventorySummary(tenantIds: string[]): Promise<Record<string, any>> {
return apiClient.post<Record<string, any>>(
'/tenants/batch/inventory-summary',
{
tenant_ids: tenantIds,
}
);
}
// ===================================================================
// OPERATIONS: Food Safety
// Backend: services/inventory/app/api/food_safety_operations.py
// ===================================================================
async acknowledgeAlert(
tenantId: string,
alertId: string,
notes?: string
): Promise<{ message: string }> {
const queryParams = new URLSearchParams();
if (notes) queryParams.append('notes', notes);
return apiClient.post<{ message: string }>(
`${this.baseUrl}/${tenantId}/inventory/food-safety/alerts/${alertId}/acknowledge?${queryParams.toString()}`
);
}
async resolveAlert(
tenantId: string,
alertId: string,
resolution: string
): Promise<{ message: string }> {
const queryParams = new URLSearchParams();
queryParams.append('resolution', resolution);
return apiClient.post<{ message: string }>(
`${this.baseUrl}/${tenantId}/inventory/food-safety/alerts/${alertId}/resolve?${queryParams.toString()}`
);
}
async getComplianceStatus(tenantId: string): Promise<FoodSafetyComplianceResponse> {
return apiClient.get<FoodSafetyComplianceResponse>(
`${this.baseUrl}/${tenantId}/inventory/food-safety/compliance/status`
);
}
// ===================================================================
// COMPLIANCE: Food Safety Alerts
// Backend: services/inventory/app/api/food_safety_alerts.py
// ===================================================================
async listFoodSafetyAlerts(
tenantId: string,
status?: string,
severity?: string,
limit: number = 50,
offset: number = 0
): Promise<FoodSafetyAlertResponse[]> {
const queryParams = new URLSearchParams();
if (status) queryParams.append('status', status);
if (severity) queryParams.append('severity', severity);
queryParams.append('limit', limit.toString());
queryParams.append('skip', offset.toString());
return apiClient.get<FoodSafetyAlertResponse[]>(
`${this.baseUrl}/${tenantId}/inventory/food-safety/alerts?${queryParams.toString()}`
);
}
// ===================================================================
// ANALYTICS: Dashboard
// Backend: services/inventory/app/api/dashboard.py
// ===================================================================
async getDashboardSummary(tenantId: string): Promise<InventoryDashboardSummary> {
return apiClient.get<InventoryDashboardSummary>(
`${this.baseUrl}/${tenantId}/inventory/dashboard/summary`
);
}
async getInventoryAnalytics(
tenantId: string,
startDate?: string,
endDate?: string
): Promise<InventoryAnalytics> {
const queryParams = new URLSearchParams();
if (startDate) queryParams.append('start_date', startDate);
if (endDate) queryParams.append('end_date', endDate);
const url = queryParams.toString()
? `${this.baseUrl}/${tenantId}/inventory/analytics?${queryParams.toString()}`
: `${this.baseUrl}/${tenantId}/inventory/analytics`;
return apiClient.get<InventoryAnalytics>(url);
}
// Legacy method - keeping for backward compatibility during transition
async getStockAnalytics(
tenantId: string,
startDate?: string,
endDate?: string
): Promise<{
total_ingredients: number;
total_stock_value: number;
low_stock_count: number;
out_of_stock_count: number;
expiring_soon_count: number;
stock_turnover_rate: number;
}> {
const queryParams = new URLSearchParams();
if (startDate) queryParams.append('start_date', startDate);
if (endDate) queryParams.append('end_date', endDate);
const url = queryParams.toString()
? `${this.baseUrl}/${tenantId}/inventory/dashboard/analytics?${queryParams.toString()}`
: `${this.baseUrl}/${tenantId}/inventory/dashboard/analytics`;
return apiClient.get(url);
}
}
export const inventoryService = new InventoryService();

View File

@@ -0,0 +1,106 @@
/**
* Nominatim Geocoding API Service
* Provides address search and autocomplete functionality
*/
import apiClient from '../client';
export interface NominatimResult {
place_id: number;
lat: string;
lon: string;
display_name: string;
address: {
road?: string;
house_number?: string;
city?: string;
town?: string;
village?: string;
municipality?: string;
postcode?: string;
country?: string;
};
boundingbox: [string, string, string, string];
}
export interface NominatimSearchParams {
q: string;
format?: 'json';
addressdetails?: 1 | 0;
limit?: number;
countrycodes?: string;
}
class NominatimService {
private baseUrl = '/api/v1/nominatim';
/**
* Search for addresses matching a query
*/
async searchAddress(query: string, limit: number = 5): Promise<NominatimResult[]> {
if (!query || query.length < 3) {
return [];
}
try {
return await apiClient.get<NominatimResult[]>(`${this.baseUrl}/search`, {
params: {
q: query,
format: 'json',
addressdetails: 1,
limit,
countrycodes: 'es', // Spain only
},
});
} catch (error) {
console.error('Address search failed:', error);
return [];
}
}
/**
* Format a Nominatim result for display
*/
formatAddress(result: NominatimResult): string {
return result.display_name;
}
/**
* Extract structured address components
*/
parseAddress(result: NominatimResult) {
const { address } = result;
return {
street: address.road
? `${address.road}${address.house_number ? ' ' + address.house_number : ''}`
: '',
city: address.city || address.town || address.village || address.municipality || '',
postalCode: address.postcode || '',
latitude: parseFloat(result.lat),
longitude: parseFloat(result.lon),
displayName: result.display_name,
};
}
/**
* Geocode a structured address to coordinates
*/
async geocodeAddress(
street: string,
city: string,
postalCode?: string
): Promise<NominatimResult | null> {
const parts = [street, city];
if (postalCode) parts.push(postalCode);
parts.push('Spain');
const query = parts.join(', ');
const results = await this.searchAddress(query, 1);
return results.length > 0 ? results[0] : null;
}
}
export const nominatimService = new NominatimService();
export default nominatimService;

View File

@@ -0,0 +1,244 @@
/**
* Onboarding Service - Mirror backend onboarding endpoints
* Frontend and backend step names now match directly!
*/
import { apiClient } from '../client';
import { UserProgress, UpdateStepRequest, SaveStepDraftRequest, StepDraftResponse } from '../types/onboarding';
// Backend onboarding steps (full list from backend - UPDATED to match refactored flow)
// NOTE: poi-detection removed - now happens automatically in background during tenant registration
export const BACKEND_ONBOARDING_STEPS = [
'user_registered', // Phase 0: User account created (auto-completed)
'bakery-type-selection', // Phase 1: Choose bakery type
'setup', // Phase 2: Basic bakery setup and tenant creation
'upload-sales-data', // Phase 2b: File upload, validation, AI classification
'inventory-review', // Phase 2b: Review AI-detected products with type selection
'initial-stock-entry', // Phase 2b: Capture initial stock levels
'product-categorization', // Phase 2c: Advanced categorization (optional)
'suppliers-setup', // Phase 2d: Suppliers configuration
'recipes-setup', // Phase 3: Production recipes (optional)
'quality-setup', // Phase 3: Quality standards (optional)
'team-setup', // Phase 3: Team members (optional)
'ml-training', // Phase 4: AI model training
'setup-review', // Phase 4: Review all configuration
'completion' // Phase 4: Onboarding completed
];
// Frontend step order for navigation (excludes user_registered as it's auto-completed)
// NOTE: poi-detection removed - now happens automatically in background during tenant registration
export const FRONTEND_STEP_ORDER = [
'bakery-type-selection', // Phase 1: Choose bakery type
'setup', // Phase 2: Basic bakery setup and tenant creation
'upload-sales-data', // Phase 2b: File upload and AI classification
'inventory-review', // Phase 2b: Review AI-detected products
'initial-stock-entry', // Phase 2b: Initial stock levels
'product-categorization', // Phase 2c: Advanced categorization (optional)
'suppliers-setup', // Phase 2d: Suppliers configuration
'recipes-setup', // Phase 3: Production recipes (optional)
'quality-setup', // Phase 3: Quality standards (optional)
'team-setup', // Phase 3: Team members (optional)
'ml-training', // Phase 4: AI model training
'setup-review', // Phase 4: Review configuration
'completion' // Phase 4: Onboarding completed
];
export class OnboardingService {
private readonly baseUrl = '/auth/me/onboarding';
async getUserProgress(userId: string): Promise<UserProgress> {
// Backend uses current user from auth token, so userId parameter is ignored
return apiClient.get<UserProgress>(`${this.baseUrl}/progress`);
}
async updateStep(userId: string, stepData: UpdateStepRequest): Promise<UserProgress> {
// Backend uses current user from auth token, so userId parameter is ignored
return apiClient.put<UserProgress>(`${this.baseUrl}/step`, stepData);
}
async markStepCompleted(
userId: string,
stepName: string,
data?: Record<string, any>
): Promise<UserProgress> {
// Backend uses current user from auth token, so userId parameter is ignored
// Backend expects UpdateStepRequest format for completion
const requestBody = {
step_name: stepName,
completed: true,
data: data,
};
console.log(`🔄 API call to mark step "${stepName}" as completed:`, requestBody);
try {
const response = await apiClient.put<UserProgress>(`${this.baseUrl}/step`, requestBody);
console.log(`✅ Step "${stepName}" marked as completed successfully:`, response);
return response;
} catch (error) {
console.error(`❌ API error marking step "${stepName}" as completed:`, error);
throw error;
}
}
async resetProgress(userId: string): Promise<UserProgress> {
// Note: Backend doesn't have a reset endpoint, this might need to be implemented
// For now, we'll throw an error
throw new Error('Reset progress functionality not implemented in backend');
}
async getStepDetails(stepName: string): Promise<{
name: string;
description: string;
dependencies: string[];
estimated_time_minutes: number;
}> {
// This endpoint doesn't exist in backend, we'll need to implement it or mock it
throw new Error('getStepDetails functionality not implemented in backend');
}
async getAllSteps(): Promise<Array<{
name: string;
description: string;
dependencies: string[];
estimated_time_minutes: number;
}>> {
// This endpoint doesn't exist in backend, we'll need to implement it or mock it
throw new Error('getAllSteps functionality not implemented in backend');
}
async getNextStep(): Promise<{ step: string; completed?: boolean }> {
// This endpoint exists in backend
return apiClient.get(`${this.baseUrl}/next-step`);
}
async canAccessStep(stepName: string): Promise<{ can_access: boolean; reason?: string }> {
// This endpoint exists in backend
return apiClient.get(`${this.baseUrl}/can-access/${stepName}`);
}
async completeOnboarding(): Promise<{ success: boolean; message: string }> {
// This endpoint exists in backend
return apiClient.post(`${this.baseUrl}/complete`);
}
/**
* Helper method to mark a step as completed (now direct mapping)
*/
async markStepAsCompleted(
stepId: string,
data?: Record<string, any>
): Promise<UserProgress> {
try {
return await this.markStepCompleted('', stepId, data);
} catch (error) {
console.error(`Error marking step ${stepId} as completed:`, error);
throw error;
}
}
/**
* Helper method to get the next step based on backend progress
*/
async getNextStepId(): Promise<string> {
try {
const result = await this.getNextStep();
return result.step || 'setup';
} catch (error) {
console.error('Error getting next step:', error);
return 'setup';
}
}
/**
* Helper method to determine which step the user should resume from
*/
async getResumeStep(): Promise<{ stepId: string; stepIndex: number }> {
try {
const progress = await this.getUserProgress('');
// If fully completed, go to completion
if (progress.fully_completed) {
return { stepId: 'completion', stepIndex: FRONTEND_STEP_ORDER.indexOf('completion') };
}
// Get the current step from backend
const currentStep = progress.current_step;
// If current step is user_registered, start from setup
const resumeStep = currentStep === 'user_registered' ? 'setup' : currentStep;
// Find the step index in our frontend order
let stepIndex = FRONTEND_STEP_ORDER.indexOf(resumeStep);
if (stepIndex === -1) {
stepIndex = 0; // Default to first step
}
return { stepId: FRONTEND_STEP_ORDER[stepIndex], stepIndex };
} catch (error) {
console.error('Error determining resume step:', error);
return { stepId: 'setup', stepIndex: 0 };
}
}
/**
* Save in-progress step data without marking the step as complete.
* This allows users to save their work and resume later.
*/
async saveStepDraft(stepName: string, draftData: Record<string, any>): Promise<{ success: boolean }> {
const requestBody: SaveStepDraftRequest = {
step_name: stepName,
draft_data: draftData,
};
console.log(`💾 Saving draft for step "${stepName}":`, draftData);
try {
const response = await apiClient.put<{ success: boolean }>(`${this.baseUrl}/step-draft`, requestBody);
console.log(`✅ Draft saved for step "${stepName}"`);
return response;
} catch (error) {
console.error(`❌ Error saving draft for step "${stepName}":`, error);
throw error;
}
}
/**
* Get saved draft data for a specific step.
* Returns null if no draft exists or the step is already completed.
*/
async getStepDraft(stepName: string): Promise<StepDraftResponse> {
console.log(`📖 Getting draft for step "${stepName}"`);
try {
const response = await apiClient.get<StepDraftResponse>(`${this.baseUrl}/step-draft/${stepName}`);
if (response.draft_data) {
console.log(`✅ Found draft for step "${stepName}":`, response.draft_data);
} else {
console.log(` No draft found for step "${stepName}"`);
}
return response;
} catch (error) {
console.error(`❌ Error getting draft for step "${stepName}":`, error);
throw error;
}
}
/**
* Delete saved draft data for a specific step.
* Called after step is completed to clean up draft data.
*/
async deleteStepDraft(stepName: string): Promise<{ success: boolean }> {
console.log(`🗑️ Deleting draft for step "${stepName}"`);
try {
const response = await apiClient.delete<{ success: boolean }>(`${this.baseUrl}/step-draft/${stepName}`);
console.log(`✅ Draft deleted for step "${stepName}"`);
return response;
} catch (error) {
console.error(`❌ Error deleting draft for step "${stepName}":`, error);
throw error;
}
}
}
export const onboardingService = new OnboardingService();

View File

@@ -0,0 +1,254 @@
/**
* Orchestrator Service API Client
* Handles coordinated workflows across Forecasting, Production, and Procurement services
*
* NEW in Sprint 2: Orchestrator Service coordinates the daily workflow:
* 1. Forecasting Service → Get demand forecasts
* 2. Production Service → Generate production schedule from forecast
* 3. Procurement Service → Generate procurement plan from forecast + schedule
*/
import { apiClient } from '../client';
import {
OrchestratorWorkflowRequest,
OrchestratorWorkflowResponse,
WorkflowExecutionSummary,
WorkflowExecutionDetail,
OrchestratorStatus,
OrchestratorConfig,
WorkflowStepResult
} from '../types/orchestrator';
// Re-export types for backward compatibility
export type {
OrchestratorWorkflowRequest,
OrchestratorWorkflowResponse,
WorkflowExecutionSummary,
WorkflowExecutionDetail,
OrchestratorStatus,
OrchestratorConfig,
WorkflowStepResult
};
// ============================================================================
// ORCHESTRATOR WORKFLOW API FUNCTIONS
// ============================================================================
/**
* Run the daily orchestrated workflow
* This is the main entry point for coordinated planning
*
* Workflow:
* 1. Forecasting Service: Get demand forecasts for target date
* 2. Production Service: Generate production schedule from forecast
* 3. Procurement Service: Generate procurement plan from forecast + schedule
*
* NEW in Sprint 2: Replaces autonomous schedulers with centralized orchestration
*/
export async function runDailyWorkflow(
tenantId: string,
request?: OrchestratorWorkflowRequest
): Promise<OrchestratorWorkflowResponse> {
return apiClient.post<OrchestratorWorkflowResponse>(
`/tenants/${tenantId}/orchestrator/run-daily-workflow`,
request || {}
);
}
/**
* Run workflow for a specific date
*/
export async function runWorkflowForDate(
tenantId: string,
targetDate: string,
options?: Omit<OrchestratorWorkflowRequest, 'target_date'>
): Promise<OrchestratorWorkflowResponse> {
return runDailyWorkflow(tenantId, {
...options,
target_date: targetDate
});
}
/**
* Test workflow with sample data (for development/testing)
*/
export async function testWorkflow(
tenantId: string
): Promise<OrchestratorWorkflowResponse> {
return apiClient.post<OrchestratorWorkflowResponse>(
`/tenants/${tenantId}/orchestrator/test-workflow`,
{}
);
}
/**
* Get list of workflow executions
*/
export async function listWorkflowExecutions(
tenantId: string,
params?: {
status?: WorkflowExecutionSummary['status'];
date_from?: string;
date_to?: string;
limit?: number;
offset?: number;
}
): Promise<WorkflowExecutionSummary[]> {
return apiClient.get<WorkflowExecutionSummary[]>(
`/tenants/${tenantId}/orchestrator/executions`,
{ params }
);
}
/**
* Get a single workflow execution by ID with full details
*/
export async function getWorkflowExecution(
tenantId: string,
executionId: string
): Promise<WorkflowExecutionDetail> {
return apiClient.get<WorkflowExecutionDetail>(
`/tenants/${tenantId}/orchestrator/executions/${executionId}`
);
}
/**
* Get latest workflow execution
*/
export async function getLatestWorkflowExecution(
tenantId: string
): Promise<WorkflowExecutionDetail | null> {
const executions = await listWorkflowExecutions(tenantId, {
limit: 1
});
if (executions.length === 0) {
return null;
}
return getWorkflowExecution(tenantId, executions[0].id);
}
/**
* Cancel a running workflow execution
*/
export async function cancelWorkflowExecution(
tenantId: string,
executionId: string
): Promise<{ message: string }> {
return apiClient.post<{ message: string }>(
`/tenants/${tenantId}/orchestrator/executions/${executionId}/cancel`,
{}
);
}
/**
* Retry a failed workflow execution
*/
export async function retryWorkflowExecution(
tenantId: string,
executionId: string
): Promise<OrchestratorWorkflowResponse> {
return apiClient.post<OrchestratorWorkflowResponse>(
`/tenants/${tenantId}/orchestrator/executions/${executionId}/retry`,
{}
);
}
// ============================================================================
// ORCHESTRATOR STATUS & HEALTH
// ============================================================================
/**
* Get orchestrator service status
*/
export async function getOrchestratorStatus(
tenantId: string
): Promise<OrchestratorStatus> {
return apiClient.get<OrchestratorStatus>(
`/tenants/${tenantId}/orchestrator/status`
);
}
/**
* Get timestamp of last orchestration run
*/
export async function getLastOrchestrationRun(
tenantId: string
): Promise<{ timestamp: string | null; runNumber: number | null }> {
return apiClient.get<{ timestamp: string | null; runNumber: number | null }>(
`/tenants/${tenantId}/orchestrator/last-run`
);
}
// ============================================================================
// ORCHESTRATOR CONFIGURATION
// ============================================================================
/**
* Get orchestrator configuration for tenant
*/
export async function getOrchestratorConfig(
tenantId: string
): Promise<OrchestratorConfig> {
return apiClient.get<OrchestratorConfig>(
`/tenants/${tenantId}/orchestrator/config`
);
}
/**
* Update orchestrator configuration
*/
export async function updateOrchestratorConfig(
tenantId: string,
config: Partial<OrchestratorConfig>
): Promise<OrchestratorConfig> {
return apiClient.put<OrchestratorConfig>(
`/tenants/${tenantId}/orchestrator/config`,
config
);
}
// ============================================================================
// HELPER FUNCTIONS
// ============================================================================
/**
* Format workflow duration for display
*/
export function formatWorkflowDuration(durationMs: number): string {
if (durationMs < 1000) {
return `${durationMs}ms`;
} else if (durationMs < 60000) {
return `${(durationMs / 1000).toFixed(1)}s`;
} else {
const minutes = Math.floor(durationMs / 60000);
const seconds = Math.floor((durationMs % 60000) / 1000);
return `${minutes}m ${seconds}s`;
}
}
/**
* Get workflow step status icon
*/
export function getWorkflowStepStatusIcon(status: WorkflowStepResult['status']): string {
switch (status) {
case 'success': return '✅';
case 'failed': return '❌';
case 'skipped': return '⏭️';
default: return '❓';
}
}
/**
* Get workflow overall status color
*/
export function getWorkflowStatusColor(status: WorkflowExecutionSummary['status']): string {
switch (status) {
case 'completed': return 'green';
case 'running': return 'blue';
case 'failed': return 'red';
case 'cancelled': return 'gray';
default: return 'gray';
}
}

View File

@@ -0,0 +1,204 @@
// ================================================================
// frontend/src/api/services/orders.ts
// ================================================================
/**
* Orders Service - Complete backend alignment
*
* Backend API structure (3-tier architecture):
* - ATOMIC: orders.py, customers.py
* - OPERATIONS: order_operations.py, procurement_operations.py
*
* Last Updated: 2025-10-05
* Status: ✅ Complete - Zero drift with backend
*/
import { apiClient } from '../client/apiClient';
import {
OrderResponse,
OrderCreate,
OrderUpdate,
CustomerResponse,
CustomerCreate,
CustomerUpdate,
OrdersDashboardSummary,
DemandRequirements,
BusinessModelDetection,
ServiceStatus,
GetOrdersParams,
GetCustomersParams,
UpdateOrderStatusParams,
GetDemandRequirementsParams,
} from '../types/orders';
export class OrdersService {
// ===================================================================
// OPERATIONS: Dashboard & Analytics
// Backend: services/orders/app/api/order_operations.py
// ===================================================================
/**
* Get comprehensive dashboard summary for orders
* GET /tenants/{tenant_id}/orders/operations/dashboard-summary
*/
static async getDashboardSummary(tenantId: string): Promise<OrdersDashboardSummary> {
return apiClient.get<OrdersDashboardSummary>(`/tenants/${tenantId}/orders/operations/dashboard-summary`);
}
/**
* Get demand requirements for production planning
* GET /tenants/{tenant_id}/orders/operations/demand-requirements
*/
static async getDemandRequirements(params: GetDemandRequirementsParams): Promise<DemandRequirements> {
const { tenant_id, target_date } = params;
return apiClient.get<DemandRequirements>(
`/tenants/${tenant_id}/orders/operations/demand-requirements?target_date=${target_date}`
);
}
// ===================================================================
// ATOMIC: Orders CRUD
// Backend: services/orders/app/api/orders.py
// ===================================================================
/**
* Create a new customer order
* POST /tenants/{tenant_id}/orders
*/
static async createOrder(orderData: OrderCreate): Promise<OrderResponse> {
const { tenant_id } = orderData;
// Note: tenant_id is in both URL path and request body (backend schema requirement)
return apiClient.post<OrderResponse>(`/tenants/${tenant_id}/orders`, orderData);
}
/**
* Get order details with items
* GET /tenants/{tenant_id}/orders/{order_id}
*/
static async getOrder(tenantId: string, orderId: string): Promise<OrderResponse> {
return apiClient.get<OrderResponse>(`/tenants/${tenantId}/orders/${orderId}`);
}
/**
* Get orders with filtering and pagination
* GET /tenants/{tenant_id}/orders
*/
static async getOrders(params: GetOrdersParams): Promise<OrderResponse[]> {
const { tenant_id, status_filter, start_date, end_date, skip = 0, limit = 100 } = params;
const queryParams = new URLSearchParams({
skip: skip.toString(),
limit: limit.toString(),
});
if (status_filter) {
queryParams.append('status_filter', status_filter);
}
if (start_date) {
queryParams.append('start_date', start_date);
}
if (end_date) {
queryParams.append('end_date', end_date);
}
return apiClient.get<OrderResponse[]>(`/tenants/${tenant_id}/orders?${queryParams.toString()}`);
}
/**
* Update order details
* PUT /tenants/{tenant_id}/orders/{order_id}
*/
static async updateOrder(tenantId: string, orderId: string, orderData: OrderUpdate): Promise<OrderResponse> {
return apiClient.put<OrderResponse>(`/tenants/${tenantId}/orders/${orderId}`, orderData);
}
/**
* Update order status
* PUT /tenants/{tenant_id}/orders/{order_id}/status
*/
static async updateOrderStatus(params: UpdateOrderStatusParams): Promise<OrderResponse> {
const { tenant_id, order_id, new_status, reason } = params;
const queryParams = new URLSearchParams();
if (reason) {
queryParams.append('reason', reason);
}
const url = `/tenants/${tenant_id}/orders/${order_id}/status${queryParams.toString() ? `?${queryParams.toString()}` : ''}`;
return apiClient.put<OrderResponse>(url, { status: new_status });
}
// ===================================================================
// ATOMIC: Customers CRUD
// Backend: services/orders/app/api/customers.py
// ===================================================================
/**
* Create a new customer
* POST /tenants/{tenant_id}/orders/customers
*/
static async createCustomer(customerData: CustomerCreate): Promise<CustomerResponse> {
const { tenant_id, ...data } = customerData;
return apiClient.post<CustomerResponse>(`/tenants/${tenant_id}/orders/customers`, data);
}
/**
* Get customers with filtering and pagination
* GET /tenants/{tenant_id}/customers
*/
static async getCustomers(params: GetCustomersParams): Promise<CustomerResponse[]> {
const { tenant_id, active_only = true, skip = 0, limit = 100 } = params;
const queryParams = new URLSearchParams({
active_only: active_only.toString(),
skip: skip.toString(),
limit: limit.toString(),
});
return apiClient.get<CustomerResponse[]>(`/tenants/${tenant_id}/orders/customers?${queryParams.toString()}`);
}
/**
* Get customer details
* GET /tenants/{tenant_id}/customers/{customer_id}
*/
static async getCustomer(tenantId: string, customerId: string): Promise<CustomerResponse> {
return apiClient.get<CustomerResponse>(`/tenants/${tenantId}/orders/customers/${customerId}`);
}
/**
* Update customer details
* PUT /tenants/{tenant_id}/customers/{customer_id}
*/
static async updateCustomer(tenantId: string, customerId: string, customerData: CustomerUpdate): Promise<CustomerResponse> {
return apiClient.put<CustomerResponse>(`/tenants/${tenantId}/orders/customers/${customerId}`, customerData);
}
// ===================================================================
// OPERATIONS: Business Intelligence
// Backend: services/orders/app/api/order_operations.py
// ===================================================================
/**
* Detect business model based on order patterns
* GET /tenants/{tenant_id}/orders/operations/business-model
*/
static async detectBusinessModel(tenantId: string): Promise<BusinessModelDetection> {
return apiClient.get<BusinessModelDetection>(`/tenants/${tenantId}/orders/operations/business-model`);
}
// ===================================================================
// Health Check
// ===================================================================
/**
* Get orders service status
* GET /tenants/{tenant_id}/orders/operations/status
*/
static async getServiceStatus(tenantId: string): Promise<ServiceStatus> {
return apiClient.get<ServiceStatus>(`/tenants/${tenantId}/orders/operations/status`);
}
}
export default OrdersService;

View File

@@ -0,0 +1,597 @@
// ================================================================
// frontend/src/api/services/pos.ts
// ================================================================
/**
* POS Service - Complete backend alignment
*
* Backend API structure (3-tier architecture):
* - ATOMIC: configurations.py, transactions.py
* - OPERATIONS: pos_operations.py
* - ANALYTICS: analytics.py
*
* Last Updated: 2025-10-05
* Status: ✅ Complete - Zero drift with backend
*/
import { apiClient } from '../client';
import type {
POSConfiguration,
POSTransaction,
POSWebhookLog,
POSSyncLog,
POSSystemInfo,
GetPOSConfigurationsRequest,
GetPOSConfigurationsResponse,
CreatePOSConfigurationRequest,
CreatePOSConfigurationResponse,
GetPOSConfigurationRequest,
GetPOSConfigurationResponse,
UpdatePOSConfigurationRequest,
UpdatePOSConfigurationResponse,
DeletePOSConfigurationRequest,
DeletePOSConfigurationResponse,
TestPOSConnectionRequest,
TestPOSConnectionResponse,
GetSupportedPOSSystemsResponse,
POSSystem,
} from '../types/pos';
export class POSService {
private readonly basePath = '/pos';
// ===================================================================
// ATOMIC: POS Configuration CRUD
// Backend: services/pos/app/api/configurations.py
// ===================================================================
/**
* Get POS configurations for a tenant
*/
async getPOSConfigurations(params: GetPOSConfigurationsRequest): Promise<GetPOSConfigurationsResponse> {
const { tenant_id, pos_system, is_active } = params;
const queryParams = new URLSearchParams();
if (pos_system) queryParams.append('pos_system', pos_system);
if (is_active !== undefined) queryParams.append('is_active', is_active.toString());
const url = `/tenants/${tenant_id}${this.basePath}/configurations${queryParams.toString() ? `?${queryParams.toString()}` : ''}`;
return apiClient.get<GetPOSConfigurationsResponse>(url);
}
/**
* Create a new POS configuration
*/
async createPOSConfiguration(params: CreatePOSConfigurationRequest): Promise<CreatePOSConfigurationResponse> {
const { tenant_id, ...configData } = params;
const url = `/tenants/${tenant_id}${this.basePath}/configurations`;
return apiClient.post<CreatePOSConfigurationResponse>(url, configData);
}
/**
* Get a specific POS configuration
*/
async getPOSConfiguration(params: GetPOSConfigurationRequest): Promise<GetPOSConfigurationResponse> {
const { tenant_id, config_id } = params;
const url = `/tenants/${tenant_id}${this.basePath}/configurations/${config_id}`;
return apiClient.get<GetPOSConfigurationResponse>(url);
}
/**
* Update a POS configuration
*/
async updatePOSConfiguration(params: UpdatePOSConfigurationRequest): Promise<UpdatePOSConfigurationResponse> {
const { tenant_id, config_id, ...updateData } = params;
const url = `/tenants/${tenant_id}${this.basePath}/configurations/${config_id}`;
return apiClient.put<UpdatePOSConfigurationResponse>(url, updateData);
}
/**
* Delete a POS configuration
*/
async deletePOSConfiguration(params: DeletePOSConfigurationRequest): Promise<DeletePOSConfigurationResponse> {
const { tenant_id, config_id } = params;
const url = `/tenants/${tenant_id}${this.basePath}/configurations/${config_id}`;
return apiClient.delete<DeletePOSConfigurationResponse>(url);
}
/**
* Test connection to POS system
*/
async testPOSConnection(params: TestPOSConnectionRequest): Promise<TestPOSConnectionResponse> {
const { tenant_id, config_id } = params;
const url = `/tenants/${tenant_id}${this.basePath}/configurations/${config_id}/test-connection`;
return apiClient.post<TestPOSConnectionResponse>(url);
}
// ===================================================================
// OPERATIONS: Supported Systems
// Backend: services/pos/app/api/pos_operations.py
// ===================================================================
/**
* Get list of supported POS systems
*/
async getSupportedPOSSystems(): Promise<GetSupportedPOSSystemsResponse> {
const url = `${this.basePath}/supported-systems`;
return apiClient.get<GetSupportedPOSSystemsResponse>(url);
}
// ===================================================================
// ATOMIC: Transactions
// Backend: services/pos/app/api/transactions.py
// ===================================================================
/**
* Get POS transactions for a tenant (Updated with backend structure)
*/
async getPOSTransactions(params: {
tenant_id: string;
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 { tenant_id, ...queryParams } = params;
const searchParams = new URLSearchParams();
Object.entries(queryParams).forEach(([key, value]) => {
if (value !== undefined) {
searchParams.append(key, value.toString());
}
});
const url = `/tenants/${tenant_id}${this.basePath}/transactions${searchParams.toString() ? `?${searchParams.toString()}` : ''}`;
return apiClient.get(url);
}
/**
* Sync a single transaction to sales service
*/
async syncSingleTransaction(params: {
tenant_id: string;
transaction_id: string;
force?: boolean;
}): Promise<{
message: string;
transaction_id: string;
sync_status: string;
sales_record_id: string;
}> {
const { tenant_id, transaction_id, force } = params;
const queryParams = new URLSearchParams();
if (force) queryParams.append('force', force.toString());
const url = `/tenants/${tenant_id}${this.basePath}/transactions/${transaction_id}/sync${queryParams.toString() ? `?${queryParams.toString()}` : ''}`;
return apiClient.post(url);
}
/**
* Get sync performance analytics
*/
async getSyncAnalytics(params: {
tenant_id: string;
days?: number;
}): Promise<{
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: any[];
error_trends: any[];
};
}> {
const { tenant_id, days } = params;
const queryParams = new URLSearchParams();
if (days) queryParams.append('days', days.toString());
const url = `/tenants/${tenant_id}${this.basePath}/analytics/sync-performance${queryParams.toString() ? `?${queryParams.toString()}` : ''}`;
return apiClient.get(url);
}
/**
* Resync failed transactions
*/
async resyncFailedTransactions(params: {
tenant_id: string;
days_back?: number;
}): Promise<{
message: string;
job_id: string;
scope: string;
estimated_transactions: number;
}> {
const { tenant_id, days_back } = params;
const queryParams = new URLSearchParams();
if (days_back) queryParams.append('days_back', days_back.toString());
const url = `/tenants/${tenant_id}${this.basePath}/data/resync${queryParams.toString() ? `?${queryParams.toString()}` : ''}`;
return apiClient.post(url);
}
/**
* Get a specific POS transaction
*/
async getPOSTransaction(params: {
tenant_id: string;
transaction_id: string;
}): Promise<POSTransaction> {
const { tenant_id, transaction_id } = params;
const url = `/tenants/${tenant_id}${this.basePath}/transactions/${transaction_id}`;
return apiClient.get(url);
}
/**
* Get POS transactions dashboard summary
*/
async getPOSTransactionsDashboard(params: {
tenant_id: string;
}): Promise<{
total_transactions_today: number;
total_transactions_this_week: number;
total_transactions_this_month: number;
revenue_today: number;
revenue_this_week: number;
revenue_this_month: number;
average_transaction_value: number;
status_breakdown: Record<string, number>;
payment_method_breakdown: Record<string, number>;
sync_status: {
synced: number;
pending: number;
failed: number;
last_sync_at?: string;
};
}> {
const { tenant_id } = params;
const url = `/tenants/${tenant_id}${this.basePath}/operations/transactions-dashboard`;
return apiClient.get(url);
}
// ===================================================================
// OPERATIONS: Sync Operations
// Backend: services/pos/app/api/pos_operations.py
// ===================================================================
/**
* Trigger manual sync for a POS configuration
*/
async triggerManualSync(params: {
tenant_id: string;
config_id: string;
sync_type?: 'full' | 'incremental';
data_types?: string[];
from_date?: string;
to_date?: string;
}): Promise<{
sync_id: string;
message: string;
status: string;
sync_type: string;
data_types: string[];
estimated_duration: string;
}> {
const { tenant_id, config_id, ...syncData } = params;
const url = `/tenants/${tenant_id}${this.basePath}/configurations/${config_id}/sync`;
return apiClient.post(url, syncData);
}
/**
* Get sync status for a POS configuration
*/
async getSyncStatus(params: {
tenant_id: string;
config_id: string;
limit?: number;
}): Promise<{
current_sync: any;
last_successful_sync: any;
recent_syncs: any[];
sync_health: {
status: string;
success_rate: number;
average_duration_minutes: number;
last_error?: string;
};
}> {
const { tenant_id, config_id, limit } = params;
const queryParams = new URLSearchParams();
if (limit) queryParams.append('limit', limit.toString());
const url = `/tenants/${tenant_id}${this.basePath}/configurations/${config_id}/sync/status${queryParams.toString() ? `?${queryParams.toString()}` : ''}`;
return apiClient.get(url);
}
/**
* Get detailed sync logs for a configuration
*/
async getDetailedSyncLogs(params: {
tenant_id: string;
config_id: string;
limit?: number;
offset?: number;
status?: string;
sync_type?: string;
data_type?: string;
}): Promise<{
logs: any[];
total: number;
has_more: boolean;
}> {
const { tenant_id, config_id, ...queryParams } = params;
const searchParams = new URLSearchParams();
Object.entries(queryParams).forEach(([key, value]) => {
if (value !== undefined) {
searchParams.append(key, value.toString());
}
});
const url = `/tenants/${tenant_id}${this.basePath}/configurations/${config_id}/sync/logs${searchParams.toString() ? `?${searchParams.toString()}` : ''}`;
return apiClient.get(url);
}
/**
* Get sync logs for a POS configuration
*/
async getSyncLogs(params: {
tenant_id: string;
config_id?: string;
status?: string;
limit?: number;
offset?: number;
}): Promise<{
sync_logs: POSSyncLog[];
total: number;
has_more: boolean;
}> {
const { tenant_id, ...queryParams } = params;
const searchParams = new URLSearchParams();
Object.entries(queryParams).forEach(([key, value]) => {
if (value !== undefined) {
searchParams.append(key, value.toString());
}
});
const url = `/tenants/${tenant_id}${this.basePath}/sync-logs${searchParams.toString() ? `?${searchParams.toString()}` : ''}`;
return apiClient.get(url);
}
// ===================================================================
// OPERATIONS: Webhook Management
// Backend: services/pos/app/api/pos_operations.py
// ===================================================================
/**
* Get webhook logs
*/
async getWebhookLogs(params: {
tenant_id: string;
pos_system?: POSSystem;
status?: string;
limit?: number;
offset?: number;
}): Promise<{
webhook_logs: POSWebhookLog[];
total: number;
has_more: boolean;
}> {
const { tenant_id, ...queryParams } = params;
const searchParams = new URLSearchParams();
Object.entries(queryParams).forEach(([key, value]) => {
if (value !== undefined) {
searchParams.append(key, value.toString());
}
});
const url = `/tenants/${tenant_id}${this.basePath}/webhook-logs${searchParams.toString() ? `?${searchParams.toString()}` : ''}`;
return apiClient.get(url);
}
/**
* Get webhook endpoint status for a POS system
*/
async getWebhookStatus(pos_system: POSSystem): Promise<{
pos_system: string;
status: string;
endpoint: string;
supported_events: {
events: string[];
format: string;
authentication: string;
};
last_received?: string;
total_received: number;
}> {
const url = `/webhooks/${pos_system}/status`;
return apiClient.get(url);
}
/**
* Process webhook (typically called by POS systems, but useful for testing)
*/
async processWebhook(params: {
pos_system: POSSystem;
payload: any;
signature?: string;
headers?: Record<string, string>;
}): Promise<{
status: string;
message?: string;
success?: boolean;
received?: boolean;
}> {
const { pos_system, payload, signature, headers = {} } = params;
const requestHeaders: Record<string, string> = {
'Content-Type': 'application/json',
...headers,
};
if (signature) {
requestHeaders['X-Webhook-Signature'] = signature;
}
const url = `/webhooks/${pos_system}`;
// Note: This would typically be called by the POS system, not the frontend
// This method is mainly for testing webhook processing
return apiClient.post(url, payload);
}
// ===================================================================
// Frontend Utility Methods
// ===================================================================
/**
* Format price for display
*/
formatPrice(amount: number, currency: string = 'EUR'): string {
return new Intl.NumberFormat('es-ES', {
style: 'currency',
currency: currency,
}).format(amount);
}
/**
* Get POS system display name
*/
getPOSSystemDisplayName(posSystem: POSSystem): string {
const systemNames: Record<POSSystem, string> = {
square: 'Square POS',
toast: 'Toast POS',
lightspeed: 'Lightspeed POS',
};
return systemNames[posSystem] || posSystem;
}
/**
* Get connection status color for UI
*/
getConnectionStatusColor(isConnected: boolean, healthStatus?: string): 'green' | 'yellow' | 'red' {
if (!isConnected) return 'red';
if (healthStatus === 'healthy') return 'green';
if (healthStatus === 'unhealthy') return 'red';
return 'yellow'; // unknown status
}
/**
* Get sync status color for UI
*/
getSyncStatusColor(status?: string): 'green' | 'yellow' | 'red' {
switch (status) {
case 'success':
return 'green';
case 'failed':
return 'red';
case 'partial':
return 'yellow';
default:
return 'yellow';
}
}
/**
* Format sync interval for display
*/
formatSyncInterval(minutes: string): string {
const mins = parseInt(minutes);
if (mins < 60) {
return `${mins} minutos`;
} else if (mins < 1440) {
const hours = Math.floor(mins / 60);
return hours === 1 ? '1 hora' : `${hours} horas`;
} else {
const days = Math.floor(mins / 1440);
return days === 1 ? '1 día' : `${days} días`;
}
}
/**
* Validate POS credentials based on system type
*/
validateCredentials(posSystem: POSSystem, credentials: Record<string, any>): {
isValid: boolean;
errors: string[];
} {
const errors: string[] = [];
switch (posSystem) {
case 'square':
if (!credentials.application_id) errors.push('Application ID es requerido');
if (!credentials.access_token) errors.push('Access Token es requerido');
if (!credentials.location_id) errors.push('Location ID es requerido');
break;
case 'toast':
if (!credentials.api_key) errors.push('API Key es requerido');
if (!credentials.restaurant_guid) errors.push('Restaurant GUID es requerido');
if (!credentials.location_id) errors.push('Location ID es requerido');
break;
case 'lightspeed':
if (!credentials.api_key) errors.push('API Key es requerido');
if (!credentials.api_secret) errors.push('API Secret es requerido');
if (!credentials.account_id) errors.push('Account ID es requerido');
if (!credentials.shop_id) errors.push('Shop ID es requerido');
break;
default:
errors.push('Sistema POS no soportado');
}
return {
isValid: errors.length === 0,
errors,
};
}
}
// Export singleton instance
export const posService = new POSService();
export default posService;

View File

@@ -0,0 +1,468 @@
// ================================================================
// frontend/src/api/services/procurement-service.ts
// ================================================================
/**
* Procurement Service - Fully aligned with backend Procurement Service API
*
* Backend API: services/procurement/app/api/
* - procurement_plans.py: Plan CRUD and generation
* - analytics.py: Analytics and dashboard
* - purchase_orders.py: PO creation from plans
*
* Base URL: /api/v1/tenants/{tenant_id}/procurement/*
*
* Last Updated: 2025-10-31
* Status: ✅ Complete - 100% backend alignment
*/
import { apiClient } from '../client/apiClient';
import {
// Procurement Plan types
ProcurementPlanResponse,
ProcurementPlanCreate,
ProcurementPlanUpdate,
PaginatedProcurementPlans,
// Procurement Requirement types
ProcurementRequirementResponse,
ProcurementRequirementUpdate,
// Dashboard & Analytics types
ProcurementDashboardData,
ProcurementTrendsData,
// Request/Response types
GeneratePlanRequest,
GeneratePlanResponse,
AutoGenerateProcurementRequest,
AutoGenerateProcurementResponse,
CreatePOsResult,
LinkRequirementToPORequest,
UpdateDeliveryStatusRequest,
ApprovalRequest,
RejectionRequest,
// Query parameter types
GetProcurementPlansParams,
GetPlanRequirementsParams,
UpdatePlanStatusParams,
} from '../types/procurement';
/**
* Procurement Service
* All methods use the standalone Procurement Service backend API
*/
export class ProcurementService {
// ===================================================================
// ANALYTICS & DASHBOARD
// Backend: services/procurement/app/api/analytics.py
// ===================================================================
/**
* Get procurement analytics dashboard data
* GET /api/v1/tenants/{tenant_id}/procurement/analytics/procurement
*/
static async getProcurementAnalytics(tenantId: string): Promise<ProcurementDashboardData> {
return apiClient.get<ProcurementDashboardData>(`/tenants/${tenantId}/procurement/analytics/procurement`);
}
/**
* Get procurement time-series trends for charts
* GET /api/v1/tenants/{tenant_id}/procurement/analytics/procurement/trends
*/
static async getProcurementTrends(tenantId: string, days: number = 7): Promise<ProcurementTrendsData> {
return apiClient.get<ProcurementTrendsData>(`/tenants/${tenantId}/procurement/analytics/procurement/trends?days=${days}`);
}
// ===================================================================
// PROCUREMENT PLAN GENERATION
// Backend: services/procurement/app/api/procurement_plans.py
// ===================================================================
/**
* Auto-generate procurement plan from forecast data (Orchestrator integration)
* POST /api/v1/tenants/{tenant_id}/procurement/operations/auto-generate
*
* Called by Orchestrator Service to create procurement plans based on forecast data
*/
static async autoGenerateProcurement(
tenantId: string,
request: AutoGenerateProcurementRequest
): Promise<AutoGenerateProcurementResponse> {
return apiClient.post<AutoGenerateProcurementResponse>(
`/tenants/${tenantId}/procurement/operations/auto-generate`,
request
);
}
/**
* Generate a new procurement plan (manual/UI-driven)
* POST /api/v1/tenants/{tenant_id}/procurement/plans
*/
static async generateProcurementPlan(
tenantId: string,
request: GeneratePlanRequest
): Promise<GeneratePlanResponse> {
return apiClient.post<GeneratePlanResponse>(
`/tenants/${tenantId}/procurement/plans`,
request
);
}
// ===================================================================
// PROCUREMENT PLAN CRUD
// Backend: services/procurement/app/api/procurement_plans.py
// ===================================================================
/**
* Get the current day's procurement plan
* GET /api/v1/tenants/{tenant_id}/procurement/plans/current
*/
static async getCurrentProcurementPlan(tenantId: string): Promise<ProcurementPlanResponse | null> {
return apiClient.get<ProcurementPlanResponse | null>(
`/tenants/${tenantId}/procurement/plans/current`
);
}
/**
* Get procurement plan by ID
* GET /api/v1/tenants/{tenant_id}/procurement/plans/{plan_id}
*/
static async getProcurementPlanById(
tenantId: string,
planId: string
): Promise<ProcurementPlanResponse | null> {
return apiClient.get<ProcurementPlanResponse | null>(
`/tenants/${tenantId}/procurement/plans/${planId}`
);
}
/**
* Get procurement plan for a specific date
* GET /api/v1/tenants/{tenant_id}/procurement/plans/date/{plan_date}
*/
static async getProcurementPlanByDate(
tenantId: string,
planDate: string
): Promise<ProcurementPlanResponse | null> {
return apiClient.get<ProcurementPlanResponse | null>(
`/tenants/${tenantId}/procurement/plans/date/${planDate}`
);
}
/**
* List all procurement plans for tenant with pagination and filtering
* GET /api/v1/tenants/{tenant_id}/procurement/plans
*/
static async getProcurementPlans(params: GetProcurementPlansParams): Promise<PaginatedProcurementPlans> {
const { tenant_id, status, start_date, end_date, limit = 50, offset = 0 } = params;
const queryParams = new URLSearchParams({
limit: limit.toString(),
offset: offset.toString(),
});
if (status) queryParams.append('status', status);
if (start_date) queryParams.append('start_date', start_date);
if (end_date) queryParams.append('end_date', end_date);
return apiClient.get<PaginatedProcurementPlans>(
`/tenants/${tenant_id}/procurement/plans?${queryParams.toString()}`
);
}
/**
* Update procurement plan status
* PATCH /api/v1/tenants/{tenant_id}/procurement/plans/{plan_id}/status
*/
static async updateProcurementPlanStatus(params: UpdatePlanStatusParams): Promise<ProcurementPlanResponse> {
const { tenant_id, plan_id, status, notes } = params;
const queryParams = new URLSearchParams({ status });
if (notes) queryParams.append('notes', notes);
return apiClient.patch<ProcurementPlanResponse>(
`/tenants/${tenant_id}/procurement/plans/${plan_id}/status?${queryParams.toString()}`,
{}
);
}
// ===================================================================
// PROCUREMENT REQUIREMENTS
// Backend: services/procurement/app/api/procurement_plans.py
// ===================================================================
/**
* Get all requirements for a procurement plan
* GET /api/v1/tenants/{tenant_id}/procurement/plans/{plan_id}/requirements
*/
static async getPlanRequirements(params: GetPlanRequirementsParams): Promise<ProcurementRequirementResponse[]> {
const { tenant_id, plan_id, status, priority } = params;
const queryParams = new URLSearchParams();
if (status) queryParams.append('status', status);
if (priority) queryParams.append('priority', priority);
const url = `/tenants/${tenant_id}/procurement/plans/${plan_id}/requirements${
queryParams.toString() ? `?${queryParams.toString()}` : ''
}`;
return apiClient.get<ProcurementRequirementResponse[]>(url);
}
/**
* Get critical requirements across all plans
* GET /api/v1/tenants/{tenant_id}/procurement/requirements/critical
*/
static async getCriticalRequirements(tenantId: string): Promise<ProcurementRequirementResponse[]> {
return apiClient.get<ProcurementRequirementResponse[]>(
`/tenants/${tenantId}/procurement/requirements/critical`
);
}
/**
* Link a procurement requirement to a purchase order
* POST /api/v1/tenants/{tenant_id}/procurement/requirements/{requirement_id}/link-purchase-order
*/
static async linkRequirementToPurchaseOrder(
tenantId: string,
requirementId: string,
request: LinkRequirementToPORequest
): Promise<{ success: boolean; message: string; requirement_id: string; purchase_order_id: string }> {
return apiClient.post<{
success: boolean;
message: string;
requirement_id: string;
purchase_order_id: string;
}>(
`/tenants/${tenantId}/procurement/requirements/${requirementId}/link-purchase-order`,
request
);
}
/**
* Update delivery status for a requirement
* PUT /api/v1/tenants/{tenant_id}/procurement/requirements/{requirement_id}/delivery-status
*/
static async updateRequirementDeliveryStatus(
tenantId: string,
requirementId: string,
request: UpdateDeliveryStatusRequest
): Promise<{ success: boolean; message: string; requirement_id: string; delivery_status: string }> {
return apiClient.put<{
success: boolean;
message: string;
requirement_id: string;
delivery_status: string;
}>(
`/tenants/${tenantId}/procurement/requirements/${requirementId}/delivery-status`,
request
);
}
// ===================================================================
// ADVANCED PROCUREMENT OPERATIONS
// Backend: services/procurement/app/api/procurement_plans.py
// ===================================================================
/**
* Recalculate an existing procurement plan
* POST /api/v1/tenants/{tenant_id}/procurement/plans/{plan_id}/recalculate
*/
static async recalculateProcurementPlan(
tenantId: string,
planId: string
): Promise<GeneratePlanResponse> {
return apiClient.post<GeneratePlanResponse>(
`/tenants/${tenantId}/procurement/plans/${planId}/recalculate`,
{}
);
}
/**
* Approve a procurement plan with optional notes
* POST /api/v1/tenants/{tenant_id}/procurement/plans/{plan_id}/approve
*/
static async approveProcurementPlan(
tenantId: string,
planId: string,
request?: ApprovalRequest
): Promise<ProcurementPlanResponse> {
return apiClient.post<ProcurementPlanResponse>(
`/tenants/${tenantId}/procurement/plans/${planId}/approve`,
request || {}
);
}
/**
* Reject a procurement plan with optional notes
* POST /api/v1/tenants/{tenant_id}/procurement/plans/{plan_id}/reject
*/
static async rejectProcurementPlan(
tenantId: string,
planId: string,
request?: RejectionRequest
): Promise<ProcurementPlanResponse> {
return apiClient.post<ProcurementPlanResponse>(
`/tenants/${tenantId}/procurement/plans/${planId}/reject`,
request || {}
);
}
// ===================================================================
// PURCHASE ORDERS
// Backend: services/procurement/app/api/purchase_orders.py
// ===================================================================
/**
* Create purchase orders from procurement plan requirements
* Groups requirements by supplier and creates POs
* POST /api/v1/tenants/{tenant_id}/procurement/plans/{plan_id}/create-purchase-orders
*/
static async createPurchaseOrdersFromPlan(
tenantId: string,
planId: string,
autoApprove: boolean = false
): Promise<CreatePOsResult> {
return apiClient.post<CreatePOsResult>(
`/tenants/${tenantId}/procurement/plans/${planId}/create-purchase-orders`,
{ auto_approve: autoApprove }
);
}
/**
* Create a new purchase order
* POST /api/v1/tenants/{tenant_id}/procurement/purchase-orders
*/
static async createPurchaseOrder(
tenantId: string,
poData: any
): Promise<PurchaseOrderResponse> {
return apiClient.post<PurchaseOrderResponse>(
`/tenants/${tenantId}/procurement/purchase-orders`,
poData
);
}
/**
* Get purchase order by ID
* GET /api/v1/tenants/{tenant_id}/procurement/purchase-orders/{po_id}
*/
static async getPurchaseOrderById(
tenantId: string,
poId: string
): Promise<PurchaseOrderWithSupplierResponse> {
return apiClient.get<PurchaseOrderWithSupplierResponse>(
`/tenants/${tenantId}/procurement/purchase-orders/${poId}`
);
}
/**
* List purchase orders
* GET /api/v1/tenants/{tenant_id}/procurement/purchase-orders
*/
static async getPurchaseOrders(
tenantId: string,
params?: { skip?: number; limit?: number; supplier_id?: string; status?: string }
): Promise<PurchaseOrderResponse[]> {
const queryParams = new URLSearchParams();
if (params?.skip !== undefined) queryParams.append('skip', params.skip.toString());
if (params?.limit !== undefined) queryParams.append('limit', params.limit.toString());
if (params?.supplier_id) queryParams.append('supplier_id', params.supplier_id);
if (params?.status) queryParams.append('status', params.status);
const queryString = queryParams.toString();
const url = `/tenants/${tenantId}/procurement/purchase-orders${queryString ? `?${queryString}` : ''}`;
return apiClient.get<PurchaseOrderResponse[]>(url);
}
/**
* Update purchase order
* PATCH /api/v1/tenants/{tenant_id}/procurement/purchase-orders/{po_id}
*/
static async updatePurchaseOrder(
tenantId: string,
poId: string,
poData: any
): Promise<PurchaseOrderResponse> {
return apiClient.patch<PurchaseOrderResponse>(
`/tenants/${tenantId}/procurement/purchase-orders/${poId}`,
poData
);
}
/**
* Update purchase order status
* PATCH /api/v1/tenants/{tenant_id}/procurement/purchase-orders/{po_id}/status
*/
static async updatePurchaseOrderStatus(
tenantId: string,
poId: string,
status: string,
notes?: string
): Promise<PurchaseOrderResponse> {
const queryParams = new URLSearchParams({ status });
if (notes) queryParams.append('notes', notes);
return apiClient.patch<PurchaseOrderResponse>(
`/tenants/${tenantId}/procurement/purchase-orders/${poId}/status?${queryParams.toString()}`,
{}
);
}
/**
* Approve or reject purchase order
* POST /api/v1/tenants/{tenant_id}/procurement/purchase-orders/{po_id}/approve
*/
static async approvePurchaseOrder(
tenantId: string,
poId: string,
approveData: any
): Promise<PurchaseOrderResponse> {
return apiClient.post<PurchaseOrderResponse>(
`/tenants/${tenantId}/procurement/purchase-orders/${poId}/approve`,
approveData
);
}
/**
* Cancel purchase order
* POST /api/v1/tenants/{tenant_id}/procurement/purchase-orders/{po_id}/cancel
*/
static async cancelPurchaseOrder(
tenantId: string,
poId: string,
reason: string,
cancelledBy?: string
): Promise<PurchaseOrderResponse> {
const queryParams = new URLSearchParams({ reason });
if (cancelledBy) queryParams.append('cancelled_by', cancelledBy);
return apiClient.post<PurchaseOrderResponse>(
`/tenants/${tenantId}/procurement/purchase-orders/${poId}/cancel?${queryParams.toString()}`,
{}
);
}
/**
* Get expected deliveries
* GET /api/v1/tenants/{tenant_id}/procurement/expected-deliveries
*/
static async getExpectedDeliveries(
tenantId: string,
params?: { days_ahead?: number; include_overdue?: boolean }
): Promise<{ deliveries: any[]; total_count: number }> {
const queryParams = new URLSearchParams();
if (params?.days_ahead !== undefined) queryParams.append('days_ahead', params.days_ahead.toString());
if (params?.include_overdue !== undefined) queryParams.append('include_overdue', params.include_overdue.toString());
const queryString = queryParams.toString();
const url = `/tenants/${tenantId}/procurement/expected-deliveries${queryString ? `?${queryString}` : ''}`;
return apiClient.get<{ deliveries: any[]; total_count: number }>(url);
}
}
export default ProcurementService;

View File

@@ -0,0 +1,445 @@
// ================================================================
// frontend/src/api/services/production.ts
// ================================================================
/**
* Production Service - Complete backend alignment
*
* Backend API structure (3-tier architecture):
* - ATOMIC: production_batches.py, production_schedules.py
* - OPERATIONS: production_operations.py (batch lifecycle, capacity management)
* - ANALYTICS: analytics.py, production_dashboard.py
*
* Last Updated: 2025-10-05
* Status: ✅ Complete - Zero drift with backend
*/
import { apiClient } from '../client/apiClient';
import {
// Batches
ProductionBatchResponse,
ProductionBatchCreate,
ProductionBatchUpdate,
ProductionBatchStatusUpdate,
ProductionBatchListResponse,
ProductionBatchFilters,
BatchStatistics,
// Schedules
ProductionScheduleResponse,
ProductionScheduleCreate,
ProductionScheduleUpdate,
ProductionScheduleFilters,
// Capacity
ProductionCapacityResponse,
ProductionCapacityFilters,
// Quality
QualityCheckResponse,
QualityCheckCreate,
QualityCheckFilters,
// Analytics
ProductionPerformanceAnalytics,
YieldTrendsAnalytics,
TopDefectsAnalytics,
EquipmentEfficiencyAnalytics,
CapacityBottlenecks,
// Dashboard
ProductionDashboardSummary,
} from '../types/production';
export class ProductionService {
private baseUrl = '/tenants';
// ===================================================================
// ATOMIC: Production Batches CRUD
// Backend: services/production/app/api/production_batches.py
// ===================================================================
async getBatches(
tenantId: string,
filters?: ProductionBatchFilters
): Promise<ProductionBatchListResponse> {
const params = new URLSearchParams();
if (filters?.status) params.append('status', filters.status);
if (filters?.product_id) params.append('product_id', filters.product_id);
if (filters?.order_id) params.append('order_id', filters.order_id);
if (filters?.start_date) params.append('start_date', filters.start_date);
if (filters?.end_date) params.append('end_date', filters.end_date);
if (filters?.page) params.append('page', filters.page.toString());
if (filters?.page_size) params.append('page_size', filters.page_size.toString());
const queryString = params.toString();
const url = `${this.baseUrl}/${tenantId}/production/batches${queryString ? `?${queryString}` : ''}`;
return apiClient.get<ProductionBatchListResponse>(url);
}
async getBatch(tenantId: string, batchId: string): Promise<ProductionBatchResponse> {
return apiClient.get<ProductionBatchResponse>(
`${this.baseUrl}/${tenantId}/production/batches/${batchId}`
);
}
async createBatch(
tenantId: string,
batchData: ProductionBatchCreate
): Promise<ProductionBatchResponse> {
return apiClient.post<ProductionBatchResponse>(
`${this.baseUrl}/${tenantId}/production/batches`,
batchData
);
}
async updateBatch(
tenantId: string,
batchId: string,
batchData: ProductionBatchUpdate
): Promise<ProductionBatchResponse> {
return apiClient.put<ProductionBatchResponse>(
`${this.baseUrl}/${tenantId}/production/batches/${batchId}`,
batchData
);
}
async deleteBatch(tenantId: string, batchId: string): Promise<void> {
return apiClient.delete<void>(`${this.baseUrl}/${tenantId}/production/batches/${batchId}`);
}
async getBatchStatistics(
tenantId: string,
startDate?: string,
endDate?: string
): Promise<BatchStatistics> {
const params = new URLSearchParams();
if (startDate) params.append('start_date', startDate);
if (endDate) params.append('end_date', endDate);
const queryString = params.toString();
const url = `${this.baseUrl}/${tenantId}/production/batches/stats${queryString ? `?${queryString}` : ''}`;
return apiClient.get<BatchStatistics>(url);
}
// ===================================================================
// ATOMIC: Production Schedules CRUD
// Backend: services/production/app/api/production_schedules.py
// ===================================================================
async getSchedules(
tenantId: string,
filters?: ProductionScheduleFilters
): Promise<{ schedules: ProductionScheduleResponse[]; total_count: number; page: number; page_size: number }> {
const params = new URLSearchParams();
if (filters?.start_date) params.append('start_date', filters.start_date);
if (filters?.end_date) params.append('end_date', filters.end_date);
if (filters?.is_finalized !== undefined)
params.append('is_finalized', filters.is_finalized.toString());
if (filters?.page) params.append('page', filters.page.toString());
if (filters?.page_size) params.append('page_size', filters.page_size.toString());
const queryString = params.toString();
const url = `${this.baseUrl}/${tenantId}/production/schedules${queryString ? `?${queryString}` : ''}`;
return apiClient.get(url);
}
async getSchedule(tenantId: string, scheduleId: string): Promise<ProductionScheduleResponse> {
return apiClient.get<ProductionScheduleResponse>(
`${this.baseUrl}/${tenantId}/production/schedules/${scheduleId}`
);
}
async createSchedule(
tenantId: string,
scheduleData: ProductionScheduleCreate
): Promise<ProductionScheduleResponse> {
return apiClient.post<ProductionScheduleResponse>(
`${this.baseUrl}/${tenantId}/production/schedules`,
scheduleData
);
}
async updateSchedule(
tenantId: string,
scheduleId: string,
scheduleData: ProductionScheduleUpdate
): Promise<ProductionScheduleResponse> {
return apiClient.put<ProductionScheduleResponse>(
`${this.baseUrl}/${tenantId}/production/schedules/${scheduleId}`,
scheduleData
);
}
async deleteSchedule(tenantId: string, scheduleId: string): Promise<void> {
return apiClient.delete<void>(`${this.baseUrl}/${tenantId}/production/schedules/${scheduleId}`);
}
async getTodaysSchedule(tenantId: string): Promise<ProductionScheduleResponse | null> {
return apiClient.get<ProductionScheduleResponse | null>(
`${this.baseUrl}/${tenantId}/production/schedules/today`
);
}
// ===================================================================
// OPERATIONS: Batch Lifecycle Management
// Backend: services/production/app/api/production_operations.py
// ===================================================================
async updateBatchStatus(
tenantId: string,
batchId: string,
statusData: ProductionBatchStatusUpdate
): Promise<ProductionBatchResponse> {
return apiClient.patch<ProductionBatchResponse>(
`${this.baseUrl}/${tenantId}/production/batches/${batchId}/status`,
statusData
);
}
async startBatch(tenantId: string, batchId: string): Promise<ProductionBatchResponse> {
return apiClient.post<ProductionBatchResponse>(
`${this.baseUrl}/${tenantId}/production/batches/${batchId}/start`
);
}
async completeBatch(
tenantId: string,
batchId: string,
completionData?: { actual_quantity?: number; notes?: string }
): Promise<ProductionBatchResponse> {
return apiClient.post<ProductionBatchResponse>(
`${this.baseUrl}/${tenantId}/production/batches/${batchId}/complete`,
completionData || {}
);
}
async finalizeSchedule(tenantId: string, scheduleId: string): Promise<ProductionScheduleResponse> {
return apiClient.post<ProductionScheduleResponse>(
`${this.baseUrl}/${tenantId}/production/schedules/${scheduleId}/finalize`
);
}
// ===================================================================
// OPERATIONS: Capacity Management
// Backend: services/production/app/api/production_operations.py
// ===================================================================
async getCapacity(
tenantId: string,
filters?: ProductionCapacityFilters
): Promise<{ capacity: ProductionCapacityResponse[]; total_count: number; page: number; page_size: number }> {
const params = new URLSearchParams();
if (filters?.resource_type) params.append('resource_type', filters.resource_type);
if (filters?.date) params.append('date', filters.date);
if (filters?.availability !== undefined)
params.append('availability', filters.availability.toString());
if (filters?.page) params.append('page', filters.page.toString());
if (filters?.page_size) params.append('page_size', filters.page_size.toString());
const queryString = params.toString();
const url = `${this.baseUrl}/${tenantId}/production/capacity${queryString ? `?${queryString}` : ''}`;
return apiClient.get(url);
}
async getCapacityByDate(tenantId: string, date: string): Promise<ProductionCapacityResponse[]> {
return apiClient.get<ProductionCapacityResponse[]>(
`${this.baseUrl}/${tenantId}/production/capacity/date/${date}`
);
}
async getCapacityByResource(
tenantId: string,
resourceId: string
): Promise<ProductionCapacityResponse[]> {
return apiClient.get<ProductionCapacityResponse[]>(
`${this.baseUrl}/${tenantId}/production/capacity/resource/${resourceId}`
);
}
// ===================================================================
// OPERATIONS: Quality Checks
// Backend: services/production/app/api/production_operations.py
// ===================================================================
async getQualityChecks(
tenantId: string,
filters?: QualityCheckFilters
): Promise<{ quality_checks: QualityCheckResponse[]; total_count: number; page: number; page_size: number }> {
const params = new URLSearchParams();
if (filters?.batch_id) params.append('batch_id', filters.batch_id);
if (filters?.product_id) params.append('product_id', filters.product_id);
if (filters?.start_date) params.append('start_date', filters.start_date);
if (filters?.end_date) params.append('end_date', filters.end_date);
if (filters?.pass_fail !== undefined) params.append('pass_fail', filters.pass_fail.toString());
if (filters?.page) params.append('page', filters.page.toString());
if (filters?.page_size) params.append('page_size', filters.page_size.toString());
const queryString = params.toString();
const url = `${this.baseUrl}/${tenantId}/production/quality-checks${queryString ? `?${queryString}` : ''}`;
return apiClient.get(url);
}
async getQualityCheck(tenantId: string, checkId: string): Promise<QualityCheckResponse> {
return apiClient.get<QualityCheckResponse>(
`${this.baseUrl}/${tenantId}/production/quality-checks/${checkId}`
);
}
async createQualityCheck(
tenantId: string,
checkData: QualityCheckCreate
): Promise<QualityCheckResponse> {
return apiClient.post<QualityCheckResponse>(
`${this.baseUrl}/${tenantId}/production/quality-checks`,
checkData
);
}
async getQualityChecksByBatch(
tenantId: string,
batchId: string
): Promise<QualityCheckResponse[]> {
return apiClient.get<QualityCheckResponse[]>(
`${this.baseUrl}/${tenantId}/production/quality-checks/batch/${batchId}`
);
}
// ===================================================================
// ANALYTICS: Performance & Trends
// Backend: services/production/app/api/analytics.py
// ===================================================================
async getPerformanceAnalytics(
tenantId: string,
startDate: string,
endDate: string
): Promise<ProductionPerformanceAnalytics> {
return apiClient.get<ProductionPerformanceAnalytics>(
`${this.baseUrl}/${tenantId}/production/analytics/performance?start_date=${startDate}&end_date=${endDate}`
);
}
async getYieldTrends(
tenantId: string,
period: 'week' | 'month' = 'week'
): Promise<YieldTrendsAnalytics> {
return apiClient.get<YieldTrendsAnalytics>(
`${this.baseUrl}/${tenantId}/production/analytics/yield-trends?period=${period}`
);
}
async getTopDefects(
tenantId: string,
startDate?: string,
endDate?: string
): Promise<TopDefectsAnalytics> {
const params = new URLSearchParams();
if (startDate) params.append('start_date', startDate);
if (endDate) params.append('end_date', endDate);
const queryString = params.toString();
const url = `${this.baseUrl}/${tenantId}/production/analytics/defects${queryString ? `?${queryString}` : ''}`;
return apiClient.get<TopDefectsAnalytics>(url);
}
async getEquipmentEfficiency(
tenantId: string,
startDate?: string,
endDate?: string
): Promise<EquipmentEfficiencyAnalytics> {
const params = new URLSearchParams();
if (startDate) params.append('start_date', startDate);
if (endDate) params.append('end_date', endDate);
const queryString = params.toString();
const url = `${this.baseUrl}/${tenantId}/production/analytics/equipment-efficiency${queryString ? `?${queryString}` : ''}`;
return apiClient.get<EquipmentEfficiencyAnalytics>(url);
}
async getCapacityBottlenecks(tenantId: string, days: number = 7): Promise<CapacityBottlenecks> {
return apiClient.get<CapacityBottlenecks>(
`${this.baseUrl}/${tenantId}/production/analytics/capacity-bottlenecks?days=${days}`
);
}
// ===================================================================
// ANALYTICS: Dashboard
// Backend: services/production/app/api/production_dashboard.py
// ===================================================================
async getDashboardSummary(tenantId: string): Promise<ProductionDashboardSummary> {
return apiClient.get<ProductionDashboardSummary>(
`${this.baseUrl}/${tenantId}/production/dashboard/summary`
);
}
async getDailyProductionPlan(tenantId: string, date?: string): Promise<any> {
const queryString = date ? `?date=${date}` : '';
return apiClient.get(`${this.baseUrl}/${tenantId}/production/dashboard/daily-plan${queryString}`);
}
async getProductionRequirements(tenantId: string, date: string): Promise<any> {
return apiClient.get(`${this.baseUrl}/${tenantId}/production/dashboard/requirements?date=${date}`);
}
async getCapacityOverview(tenantId: string, date?: string): Promise<any> {
const queryString = date ? `?date=${date}` : '';
return apiClient.get(
`${this.baseUrl}/${tenantId}/production/dashboard/capacity-overview${queryString}`
);
}
async getQualityOverview(
tenantId: string,
startDate?: string,
endDate?: string
): Promise<any> {
const params = new URLSearchParams();
if (startDate) params.append('start_date', startDate);
if (endDate) params.append('end_date', endDate);
const queryString = params.toString();
const url = `${this.baseUrl}/${tenantId}/production/dashboard/quality-overview${queryString ? `?${queryString}` : ''}`;
return apiClient.get(url);
}
// ===================================================================
// ANALYTICS: Batch Production Summary (Enterprise Feature)
// Backend: services/production/app/api/analytics.py
// ===================================================================
async getBatchProductionSummary(tenantIds: string[]): Promise<Record<string, any>> {
return apiClient.post<Record<string, any>>(
'/tenants/batch/production-summary',
{
tenant_ids: tenantIds,
}
);
}
// ===================================================================
// OPERATIONS: Scheduler
// ===================================================================
/**
* Trigger production scheduler manually (for testing/development)
* POST /tenants/{tenant_id}/production/operations/scheduler/trigger
*/
static async triggerProductionScheduler(tenantId: string): Promise<{
success: boolean;
message: string;
tenant_id: string
}> {
return apiClient.post(
`/tenants/${tenantId}/production/operations/scheduler/trigger`,
{}
);
}
}
export const productionService = new ProductionService();
export default productionService;

View File

@@ -0,0 +1,345 @@
/**
* Purchase Orders API Client
* Handles all API calls for purchase orders
*
* UPDATED in Sprint 3: Purchase orders now managed by Procurement Service
* Previously: Suppliers Service (/tenants/{id}/purchase-orders)
* Now: Procurement Service (/tenants/{id}/procurement/purchase-orders)
*/
import { apiClient } from '../client';
export type PurchaseOrderStatus =
| 'DRAFT'
| 'PENDING_APPROVAL'
| 'APPROVED'
| 'SENT_TO_SUPPLIER'
| 'CONFIRMED'
| 'RECEIVED'
| 'COMPLETED'
| 'CANCELLED'
| 'DISPUTED';
export type PurchaseOrderPriority = 'urgent' | 'high' | 'normal' | 'low';
export interface PurchaseOrderItem {
id: string;
inventory_product_id: string;
product_code?: string;
product_name?: string;
ordered_quantity: number;
unit_of_measure: string;
unit_price: string; // Decimal as string
line_total: string; // Decimal as string
received_quantity: number;
remaining_quantity: number;
quality_requirements?: string;
item_notes?: string;
}
export interface SupplierSummary {
id: string;
name: string;
supplier_code: string;
supplier_type: string;
status: string;
contact_person?: string;
email?: string;
phone?: string;
}
export interface PurchaseOrderSummary {
id: string;
po_number: string;
supplier_id: string;
supplier_name?: string;
status: PurchaseOrderStatus;
priority: PurchaseOrderPriority;
order_date: string;
required_delivery_date?: string;
total_amount: string; // Decimal as string
currency: string;
created_at: string;
reasoning_data?: any; // AI reasoning data for dashboard display
ai_reasoning_summary?: string; // Human-readable summary
}
export interface PurchaseOrderDetail extends PurchaseOrderSummary {
reference_number?: string;
estimated_delivery_date?: string;
// Financial information
subtotal: string;
tax_amount: string;
shipping_cost: string;
discount_amount: string;
// Delivery information
delivery_address?: string;
delivery_instructions?: string;
delivery_contact?: string;
delivery_phone?: string;
// Approval workflow
requires_approval: boolean;
approved_by?: string;
approved_at?: string;
rejection_reason?: string;
// Communication tracking
sent_to_supplier_at?: string;
supplier_confirmation_date?: string;
supplier_reference?: string;
// Additional information
notes?: string;
internal_notes?: string;
terms_and_conditions?: string;
// Audit fields
updated_at: string;
created_by: string;
updated_by: string;
// Related data
supplier?: SupplierSummary;
items?: PurchaseOrderItem[];
}
export interface PurchaseOrderSearchParams {
supplier_id?: string;
status?: PurchaseOrderStatus;
priority?: PurchaseOrderPriority;
date_from?: string; // YYYY-MM-DD
date_to?: string; // YYYY-MM-DD
search_term?: string;
limit?: number;
skip?: number; // ✅ Changed from "offset" to "skip" to match backend
}
export interface PurchaseOrderUpdateData {
status?: PurchaseOrderStatus;
priority?: PurchaseOrderPriority;
notes?: string;
rejection_reason?: string;
internal_notes?: string;
}
export interface PurchaseOrderItemCreate {
inventory_product_id: string;
ordered_quantity: number;
unit_price: string; // Decimal as string
unit_of_measure: string;
quality_requirements?: string;
item_notes?: string;
}
export interface PurchaseOrderCreateData {
supplier_id: string;
required_delivery_date?: string;
priority?: PurchaseOrderPriority;
tax_amount?: number;
shipping_cost?: number;
discount_amount?: number;
notes?: string;
procurement_plan_id?: string;
items: PurchaseOrderItemCreate[];
}
/**
* Create a new purchase order
*/
export async function createPurchaseOrder(
tenantId: string,
data: PurchaseOrderCreateData
): Promise<PurchaseOrderDetail> {
return apiClient.post<PurchaseOrderDetail>(
`/tenants/${tenantId}/procurement/purchase-orders`,
data
);
}
/**
* Get list of purchase orders with optional filters
*/
export async function listPurchaseOrders(
tenantId: string,
params?: PurchaseOrderSearchParams
): Promise<PurchaseOrderSummary[]> {
return apiClient.get<PurchaseOrderSummary[]>(
`/tenants/${tenantId}/procurement/purchase-orders`,
{ params }
);
}
/**
* Get purchase orders by status
*/
export async function getPurchaseOrdersByStatus(
tenantId: string,
status: PurchaseOrderStatus,
limit: number = 50
): Promise<PurchaseOrderSummary[]> {
return listPurchaseOrders(tenantId, { status, limit });
}
/**
* Get pending approval purchase orders
*/
export async function getPendingApprovalPurchaseOrders(
tenantId: string,
limit: number = 50
): Promise<PurchaseOrderSummary[]> {
return getPurchaseOrdersByStatus(tenantId, 'PENDING_APPROVAL', limit);
}
/**
* Get a single purchase order by ID with full details
*/
export async function getPurchaseOrder(
tenantId: string,
poId: string
): Promise<PurchaseOrderDetail> {
return apiClient.get<PurchaseOrderDetail>(
`/tenants/${tenantId}/procurement/purchase-orders/${poId}`
);
}
/**
* Update purchase order
*/
export async function updatePurchaseOrder(
tenantId: string,
poId: string,
data: PurchaseOrderUpdateData
): Promise<PurchaseOrderDetail> {
return apiClient.put<PurchaseOrderDetail>(
`/tenants/${tenantId}/procurement/purchase-orders/${poId}`,
data
);
}
/**
* Approve a purchase order
*/
export async function approvePurchaseOrder(
tenantId: string,
poId: string,
notes?: string
): Promise<PurchaseOrderDetail> {
return apiClient.post<PurchaseOrderDetail>(
`/tenants/${tenantId}/procurement/purchase-orders/${poId}/approve`,
{
action: 'approve',
notes: notes || 'Approved from dashboard'
}
);
}
/**
* Reject a purchase order
*/
export async function rejectPurchaseOrder(
tenantId: string,
poId: string,
reason: string
): Promise<PurchaseOrderDetail> {
return apiClient.post<PurchaseOrderDetail>(
`/tenants/${tenantId}/procurement/purchase-orders/${poId}/approve`,
{
action: 'reject',
notes: reason
}
);
}
/**
* Bulk approve purchase orders
*/
export async function bulkApprovePurchaseOrders(
tenantId: string,
poIds: string[],
notes?: string
): Promise<PurchaseOrderDetail[]> {
const approvalPromises = poIds.map(poId =>
approvePurchaseOrder(tenantId, poId, notes)
);
return Promise.all(approvalPromises);
}
/**
* Delete purchase order
*/
export async function deletePurchaseOrder(
tenantId: string,
poId: string
): Promise<{ message: string }> {
return apiClient.delete<{ message: string }>(
`/tenants/${tenantId}/procurement/purchase-orders/${poId}`
);
}
// ================================================================
// DELIVERY TYPES AND METHODS
// ================================================================
export interface DeliveryItemInput {
purchase_order_item_id: string;
inventory_product_id: string;
ordered_quantity: number;
delivered_quantity: number;
accepted_quantity: number;
rejected_quantity: number;
batch_lot_number?: string;
expiry_date?: string;
quality_grade?: string;
quality_issues?: string;
rejection_reason?: string;
item_notes?: string;
}
export interface CreateDeliveryInput {
purchase_order_id: string;
supplier_id: string;
supplier_delivery_note?: string;
scheduled_date?: string;
estimated_arrival?: string;
carrier_name?: string;
tracking_number?: string;
inspection_passed?: boolean;
inspection_notes?: string;
notes?: string;
items: DeliveryItemInput[];
}
export interface DeliveryResponse {
id: string;
tenant_id: string;
purchase_order_id: string;
supplier_id: string;
delivery_number: string;
status: string;
scheduled_date?: string;
estimated_arrival?: string;
actual_arrival?: string;
completed_at?: string;
inspection_passed?: boolean;
inspection_notes?: string;
notes?: string;
created_at: string;
updated_at: string;
}
/**
* Create delivery for purchase order
*/
export async function createDelivery(
tenantId: string,
poId: string,
deliveryData: CreateDeliveryInput
): Promise<DeliveryResponse> {
return apiClient.post<DeliveryResponse>(
`/tenants/${tenantId}/procurement/purchase-orders/${poId}/deliveries`,
deliveryData
);
}

View File

@@ -0,0 +1,205 @@
// frontend/src/api/services/qualityTemplates.ts
/**
* Quality Check Template API service
*/
import { apiClient } from '../client';
import type {
QualityCheckTemplate,
QualityCheckTemplateCreate,
QualityCheckTemplateUpdate,
QualityCheckTemplateList,
QualityTemplateQueryParams,
ProcessStage,
QualityCheckExecutionRequest,
QualityCheckExecutionResponse
} from '../types/qualityTemplates';
class QualityTemplateService {
private readonly baseURL = '/tenants';
/**
* Create a new quality check template
*/
async createTemplate(
tenantId: string,
templateData: QualityCheckTemplateCreate
): Promise<QualityCheckTemplate> {
const data = await apiClient.post(`${this.baseURL}/${tenantId}/production/quality-templates`, templateData, {
headers: { 'X-Tenant-ID': tenantId }
});
return data;
}
/**
* Get quality check templates with filtering and pagination
*/
async getTemplates(
tenantId: string,
params?: QualityTemplateQueryParams
): Promise<QualityCheckTemplateList> {
const data = await apiClient.get(`${this.baseURL}/${tenantId}/production/quality-templates`, {
params,
headers: { 'X-Tenant-ID': tenantId }
});
return data;
}
/**
* Get a specific quality check template
*/
async getTemplate(
tenantId: string,
templateId: string
): Promise<QualityCheckTemplate> {
const data = await apiClient.get(`${this.baseURL}/${tenantId}/production/quality-templates/${templateId}`, {
headers: { 'X-Tenant-ID': tenantId }
});
return data;
}
/**
* Update a quality check template
*/
async updateTemplate(
tenantId: string,
templateId: string,
templateData: QualityCheckTemplateUpdate
): Promise<QualityCheckTemplate> {
const data = await apiClient.put(`${this.baseURL}/${tenantId}/production/quality-templates/${templateId}`, templateData, {
headers: { 'X-Tenant-ID': tenantId }
});
return data;
}
/**
* Delete a quality check template
*/
async deleteTemplate(tenantId: string, templateId: string): Promise<void> {
await apiClient.delete(`${this.baseURL}/${tenantId}/production/quality-templates/${templateId}`, {
headers: { 'X-Tenant-ID': tenantId }
});
}
/**
* Get templates applicable to a specific process stage
*/
async getTemplatesForStage(
tenantId: string,
stage: ProcessStage,
isActive: boolean = true
): Promise<QualityCheckTemplateList> {
const data = await apiClient.get(`${this.baseURL}/${tenantId}/production/quality-templates/stages/${stage}`, {
params: { is_active: isActive },
headers: { 'X-Tenant-ID': tenantId }
});
return data;
}
/**
* Duplicate an existing quality check template
*/
async duplicateTemplate(
tenantId: string,
templateId: string
): Promise<QualityCheckTemplate> {
const data = await apiClient.post(`${this.baseURL}/${tenantId}/production/quality-templates/${templateId}/duplicate`, {}, {
headers: { 'X-Tenant-ID': tenantId }
});
return data;
}
/**
* Execute a quality check using a template
*/
async executeQualityCheck(
tenantId: string,
executionData: QualityCheckExecutionRequest
): Promise<QualityCheckExecutionResponse> {
const data = await apiClient.post(`${this.baseURL}/${tenantId}/production/quality-checks/execute`, executionData, {
headers: { 'X-Tenant-ID': tenantId }
});
return data;
}
/**
* Get quality check history for a batch
*/
async getQualityCheckHistory(
tenantId: string,
batchId: string,
stage?: ProcessStage
): Promise<any[]> {
const data = await apiClient.get(`${this.baseURL}/${tenantId}/production/quality-checks`, {
params: { batch_id: batchId, process_stage: stage },
headers: { 'X-Tenant-ID': tenantId }
});
return data;
}
/**
* Get quality check templates for recipe configuration
*/
async getTemplatesForRecipe(
tenantId: string,
recipeId: string
): Promise<Record<ProcessStage, QualityCheckTemplate[]>> {
const allTemplates = await this.getTemplates(tenantId, { is_active: true });
// Group templates by applicable stages
const templatesByStage: Record<ProcessStage, QualityCheckTemplate[]> = {} as any;
Object.values(ProcessStage).forEach(stage => {
templatesByStage[stage] = allTemplates.templates.filter(template =>
!template.applicable_stages ||
template.applicable_stages.length === 0 ||
template.applicable_stages.includes(stage)
);
});
return templatesByStage;
}
/**
* Validate template configuration
*/
async validateTemplate(
tenantId: string,
templateData: Partial<QualityCheckTemplateCreate | QualityCheckTemplateUpdate>
): Promise<{ valid: boolean; errors: string[] }> {
try {
const data = await apiClient.post(`${this.baseURL}/${tenantId}/production/quality-templates/validate`, templateData, {
headers: { 'X-Tenant-ID': tenantId }
});
return data;
} catch (error: any) {
if (error.response?.status === 400) {
return {
valid: false,
errors: [error.response?.data?.detail || 'Validation failed']
};
}
throw error;
}
}
/**
* Get default template suggestions based on product type
*/
async getDefaultTemplates(
tenantId: string,
productCategory: string
): Promise<QualityCheckTemplate[]> {
const templates = await this.getTemplates(tenantId, {
is_active: true,
category: productCategory
});
// Return commonly used templates for the product category
return templates.templates.filter(template =>
template.is_required || template.weight > 5.0
).sort((a, b) => b.weight - a.weight);
}
}
export const qualityTemplateService = new QualityTemplateService();

View File

@@ -0,0 +1,225 @@
// ================================================================
// frontend/src/api/services/recipes.ts
// ================================================================
/**
* Recipes Service - Complete backend alignment
*
* Backend API structure (3-tier architecture):
* - ATOMIC: recipes.py, recipe_quality_configs.py
* - OPERATIONS: recipe_operations.py (duplicate, activate, feasibility)
*
* Last Updated: 2025-10-05
* Status: ✅ Complete - Zero drift with backend
*/
import { apiClient } from '../client/apiClient';
import type {
RecipeResponse,
RecipeCreate,
RecipeUpdate,
RecipeSearchParams,
RecipeDuplicateRequest,
RecipeFeasibilityResponse,
RecipeStatisticsResponse,
RecipeCategoriesResponse,
RecipeQualityConfiguration,
RecipeQualityConfigurationUpdate,
RecipeDeletionSummary,
} from '../types/recipes';
export class RecipesService {
private readonly baseUrl = '/tenants';
// ===================================================================
// ATOMIC: Recipes CRUD
// Backend: services/recipes/app/api/recipes.py
// ===================================================================
/**
* Create a new recipe
* POST /tenants/{tenant_id}/recipes
*/
async createRecipe(tenantId: string, recipeData: RecipeCreate): Promise<RecipeResponse> {
return apiClient.post<RecipeResponse>(`${this.baseUrl}/${tenantId}/recipes`, recipeData);
}
/**
* Search recipes with filters
* GET /tenants/{tenant_id}/recipes
*/
async searchRecipes(tenantId: string, params: RecipeSearchParams = {}): Promise<RecipeResponse[]> {
const searchParams = new URLSearchParams();
// Add all non-empty parameters to the query string
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined && value !== null && value !== '') {
searchParams.append(key, String(value));
}
});
const queryString = searchParams.toString();
const url = queryString ? `${this.baseUrl}/${tenantId}/recipes?${queryString}` : `${this.baseUrl}/${tenantId}/recipes`;
return apiClient.get<RecipeResponse[]>(url);
}
/**
* Get all recipes (shorthand for search without filters)
* GET /tenants/{tenant_id}/recipes
*/
async getRecipes(tenantId: string): Promise<RecipeResponse[]> {
return this.searchRecipes(tenantId);
}
/**
* Get recipe by ID with ingredients
* GET /tenants/{tenant_id}/recipes/{recipe_id}
*/
async getRecipe(tenantId: string, recipeId: string): Promise<RecipeResponse> {
return apiClient.get<RecipeResponse>(`${this.baseUrl}/${tenantId}/recipes/${recipeId}`);
}
/**
* Update an existing recipe
* PUT /tenants/{tenant_id}/recipes/{recipe_id}
*/
async updateRecipe(tenantId: string, recipeId: string, recipeData: RecipeUpdate): Promise<RecipeResponse> {
return apiClient.put<RecipeResponse>(`${this.baseUrl}/${tenantId}/recipes/${recipeId}`, recipeData);
}
/**
* Delete a recipe
* DELETE /tenants/{tenant_id}/recipes/{recipe_id}
*/
async deleteRecipe(tenantId: string, recipeId: string): Promise<{ message: string }> {
return apiClient.delete<{ message: string }>(`${this.baseUrl}/${tenantId}/recipes/${recipeId}`);
}
/**
* Archive a recipe (soft delete by setting status to ARCHIVED)
* PATCH /tenants/{tenant_id}/recipes/{recipe_id}/archive
*/
async archiveRecipe(tenantId: string, recipeId: string): Promise<RecipeResponse> {
return apiClient.patch<RecipeResponse>(`${this.baseUrl}/${tenantId}/recipes/${recipeId}/archive`);
}
/**
* Get deletion summary for a recipe
* GET /tenants/{tenant_id}/recipes/{recipe_id}/deletion-summary
*/
async getRecipeDeletionSummary(tenantId: string, recipeId: string): Promise<RecipeDeletionSummary> {
return apiClient.get<RecipeDeletionSummary>(`${this.baseUrl}/${tenantId}/recipes/${recipeId}/deletion-summary`);
}
// ===================================================================
// ATOMIC: Quality Configuration CRUD
// Backend: services/recipes/app/api/recipe_quality_configs.py
// ===================================================================
/**
* Get quality configuration for a recipe
* GET /tenants/{tenant_id}/recipes/{recipe_id}/quality-configuration
*/
async getRecipeQualityConfiguration(
tenantId: string,
recipeId: string
): Promise<RecipeQualityConfiguration> {
return apiClient.get<RecipeQualityConfiguration>(`${this.baseUrl}/${tenantId}/recipes/${recipeId}/quality-configuration`);
}
/**
* Update quality configuration for a recipe
* PUT /tenants/{tenant_id}/recipes/{recipe_id}/quality-configuration
*/
async updateRecipeQualityConfiguration(
tenantId: string,
recipeId: string,
qualityConfig: RecipeQualityConfigurationUpdate
): Promise<RecipeQualityConfiguration> {
return apiClient.put<RecipeQualityConfiguration>(
`${this.baseUrl}/${tenantId}/recipes/${recipeId}/quality-configuration`,
qualityConfig
);
}
/**
* Add quality templates to a recipe stage
* POST /tenants/{tenant_id}/recipes/{recipe_id}/quality-configuration/stages/{stage}/templates
*/
async addQualityTemplatesToStage(
tenantId: string,
recipeId: string,
stage: string,
templateIds: string[]
): Promise<{ message: string }> {
return apiClient.post<{ message: string }>(
`${this.baseUrl}/${tenantId}/recipes/${recipeId}/quality-configuration/stages/${stage}/templates`,
templateIds
);
}
/**
* Remove a quality template from a recipe stage
* DELETE /tenants/{tenant_id}/recipes/{recipe_id}/quality-configuration/stages/{stage}/templates/{template_id}
*/
async removeQualityTemplateFromStage(
tenantId: string,
recipeId: string,
stage: string,
templateId: string
): Promise<{ message: string }> {
return apiClient.delete<{ message: string }>(
`${this.baseUrl}/${tenantId}/recipes/${recipeId}/quality-configuration/stages/${stage}/templates/${templateId}`
);
}
// ===================================================================
// OPERATIONS: Recipe Management
// Backend: services/recipes/app/api/recipe_operations.py
// ===================================================================
/**
* Duplicate an existing recipe
* POST /tenants/{tenant_id}/recipes/{recipe_id}/duplicate
*/
async duplicateRecipe(tenantId: string, recipeId: string, duplicateData: RecipeDuplicateRequest): Promise<RecipeResponse> {
return apiClient.post<RecipeResponse>(`${this.baseUrl}/${tenantId}/recipes/${recipeId}/duplicate`, duplicateData);
}
/**
* Activate a recipe for production
* POST /tenants/{tenant_id}/recipes/{recipe_id}/activate
*/
async activateRecipe(tenantId: string, recipeId: string): Promise<RecipeResponse> {
return apiClient.post<RecipeResponse>(`${this.baseUrl}/${tenantId}/recipes/${recipeId}/activate`);
}
/**
* Check if recipe can be produced with current inventory
* GET /tenants/{tenant_id}/recipes/{recipe_id}/feasibility
*/
async checkRecipeFeasibility(tenantId: string, recipeId: string, batchMultiplier: number = 1.0): Promise<RecipeFeasibilityResponse> {
const params = new URLSearchParams({ batch_multiplier: String(batchMultiplier) });
return apiClient.get<RecipeFeasibilityResponse>(`${this.baseUrl}/${tenantId}/recipes/${recipeId}/feasibility?${params}`);
}
/**
* Get recipe statistics for dashboard
* GET /tenants/{tenant_id}/recipes/dashboard/statistics
*/
async getRecipeStatistics(tenantId: string): Promise<RecipeStatisticsResponse> {
return apiClient.get<RecipeStatisticsResponse>(`${this.baseUrl}/${tenantId}/recipes/dashboard/statistics`);
}
/**
* Get list of recipe categories used by tenant
* GET /tenants/{tenant_id}/recipes/categories/list
*/
async getRecipeCategories(tenantId: string): Promise<RecipeCategoriesResponse> {
return apiClient.get<RecipeCategoriesResponse>(`${this.baseUrl}/${tenantId}/recipes/categories/list`);
}
}
// Create and export singleton instance
export const recipesService = new RecipesService();
export default recipesService;

View File

@@ -0,0 +1,294 @@
// ================================================================
// frontend/src/api/services/sales.ts
// ================================================================
/**
* Sales Service - Complete backend alignment
*
* Backend API structure (3-tier architecture):
* - ATOMIC: sales_records.py
* - OPERATIONS: sales_operations.py (validation, import, aggregation)
* - ANALYTICS: analytics.py
*
* Last Updated: 2025-10-05
* Status: ✅ Complete - Zero drift with backend
*/
import { apiClient } from '../client';
import {
// Sales Data
SalesDataCreate,
SalesDataUpdate,
SalesDataResponse,
SalesDataQuery,
// Import
ImportValidationResult,
BulkImportResponse,
ImportSummary,
// Analytics
SalesAnalytics,
ProductSalesAnalytics,
CategorySalesAnalytics,
ChannelPerformance,
} from '../types/sales';
export class SalesService {
private readonly baseUrl = '/tenants';
// ===================================================================
// ATOMIC: Sales Records CRUD
// Backend: services/sales/app/api/sales_records.py
// ===================================================================
async createSalesRecord(
tenantId: string,
salesData: SalesDataCreate
): Promise<SalesDataResponse> {
return apiClient.post<SalesDataResponse>(
`${this.baseUrl}/${tenantId}/sales/sales`,
salesData
);
}
async getSalesRecords(
tenantId: string,
query?: SalesDataQuery
): Promise<SalesDataResponse[]> {
const queryParams = new URLSearchParams();
if (query?.start_date) queryParams.append('start_date', query.start_date);
if (query?.end_date) queryParams.append('end_date', query.end_date);
if (query?.product_name) queryParams.append('product_name', query.product_name);
if (query?.product_category) queryParams.append('product_category', query.product_category);
if (query?.location_id) queryParams.append('location_id', query.location_id);
if (query?.sales_channel) queryParams.append('sales_channel', query.sales_channel);
if (query?.source) queryParams.append('source', query.source);
if (query?.is_validated !== undefined)
queryParams.append('is_validated', query.is_validated.toString());
if (query?.limit !== undefined) queryParams.append('limit', query.limit.toString());
if (query?.offset !== undefined) queryParams.append('offset', query.offset.toString());
if (query?.order_by) queryParams.append('order_by', query.order_by);
if (query?.order_direction) queryParams.append('order_direction', query.order_direction);
const url = queryParams.toString()
? `${this.baseUrl}/${tenantId}/sales/sales?${queryParams.toString()}`
: `${this.baseUrl}/${tenantId}/sales/sales`;
return apiClient.get<SalesDataResponse[]>(url);
}
async getSalesRecord(tenantId: string, recordId: string): Promise<SalesDataResponse> {
return apiClient.get<SalesDataResponse>(
`${this.baseUrl}/${tenantId}/sales/sales/${recordId}`
);
}
async updateSalesRecord(
tenantId: string,
recordId: string,
updateData: SalesDataUpdate
): Promise<SalesDataResponse> {
return apiClient.put<SalesDataResponse>(
`${this.baseUrl}/${tenantId}/sales/sales/${recordId}`,
updateData
);
}
async deleteSalesRecord(tenantId: string, recordId: string): Promise<{ message: string }> {
return apiClient.delete<{ message: string }>(
`${this.baseUrl}/${tenantId}/sales/sales/${recordId}`
);
}
async getProductCategories(tenantId: string): Promise<string[]> {
return apiClient.get<string[]>(`${this.baseUrl}/${tenantId}/sales/categories`);
}
// ===================================================================
// OPERATIONS: Validation
// Backend: services/sales/app/api/sales_operations.py
// ===================================================================
async validateSalesRecord(
tenantId: string,
recordId: string,
validationNotes?: string
): Promise<SalesDataResponse> {
const queryParams = new URLSearchParams();
if (validationNotes) queryParams.append('validation_notes', validationNotes);
const url = queryParams.toString()
? `${this.baseUrl}/${tenantId}/sales/operations/validate-record/${recordId}?${queryParams.toString()}`
: `${this.baseUrl}/${tenantId}/sales/operations/validate-record/${recordId}`;
return apiClient.post<SalesDataResponse>(url);
}
// ===================================================================
// OPERATIONS: Cross-Service Queries
// Backend: services/sales/app/api/sales_operations.py
// ===================================================================
async getProductSales(
tenantId: string,
inventoryProductId: string,
startDate?: string,
endDate?: string
): Promise<SalesDataResponse[]> {
const queryParams = new URLSearchParams();
if (startDate) queryParams.append('start_date', startDate);
if (endDate) queryParams.append('end_date', endDate);
const url = queryParams.toString()
? `${this.baseUrl}/${tenantId}/inventory-products/${inventoryProductId}/sales?${queryParams.toString()}`
: `${this.baseUrl}/${tenantId}/inventory-products/${inventoryProductId}/sales`;
return apiClient.get<SalesDataResponse[]>(url);
}
// ===================================================================
// OPERATIONS: Data Import
// Backend: services/sales/app/api/sales_operations.py
// ===================================================================
async validateImportFile(tenantId: string, file: File): Promise<ImportValidationResult> {
const formData = new FormData();
formData.append('file', file);
return apiClient.uploadFile<ImportValidationResult>(
`${this.baseUrl}/${tenantId}/sales/operations/import/validate`,
formData
);
}
async importSalesData(
tenantId: string,
file: File,
skipValidation: boolean = false
): Promise<BulkImportResponse> {
const formData = new FormData();
formData.append('file', file);
formData.append('skip_validation', skipValidation.toString());
return apiClient.uploadFile<BulkImportResponse>(
`${this.baseUrl}/${tenantId}/sales/operations/import`,
formData
);
}
async getImportHistory(
tenantId: string,
limit: number = 50,
offset: number = 0
): Promise<ImportSummary[]> {
const queryParams = new URLSearchParams();
queryParams.append('limit', limit.toString());
queryParams.append('offset', offset.toString());
return apiClient.get<ImportSummary[]>(
`${this.baseUrl}/${tenantId}/sales/operations/import/history?${queryParams.toString()}`
);
}
async downloadImportTemplate(tenantId: string): Promise<Blob> {
return apiClient.get<Blob>(
`${this.baseUrl}/${tenantId}/sales/operations/import/template`,
{ responseType: 'blob' }
);
}
// ===================================================================
// OPERATIONS: Batch Sales Summary (Enterprise Feature)
// Backend: services/sales/app/api/sales_operations.py
// ===================================================================
async getBatchSalesSummary(
tenantIds: string[],
startDate: string,
endDate: string
): Promise<Record<string, any>> {
return apiClient.post<Record<string, any>>(
'/tenants/batch/sales-summary',
{
tenant_ids: tenantIds,
start_date: startDate,
end_date: endDate,
}
);
}
// ===================================================================
// OPERATIONS: Aggregation
// Backend: services/sales/app/api/sales_operations.py
// ===================================================================
async aggregateSalesByProduct(
tenantId: string,
startDate?: string,
endDate?: string
): Promise<ProductSalesAnalytics[]> {
const queryParams = new URLSearchParams();
if (startDate) queryParams.append('start_date', startDate);
if (endDate) queryParams.append('end_date', endDate);
const url = queryParams.toString()
? `${this.baseUrl}/${tenantId}/sales/operations/aggregate/by-product?${queryParams.toString()}`
: `${this.baseUrl}/${tenantId}/sales/operations/aggregate/by-product`;
return apiClient.get<ProductSalesAnalytics[]>(url);
}
async aggregateSalesByCategory(
tenantId: string,
startDate?: string,
endDate?: string
): Promise<CategorySalesAnalytics[]> {
const queryParams = new URLSearchParams();
if (startDate) queryParams.append('start_date', startDate);
if (endDate) queryParams.append('end_date', endDate);
const url = queryParams.toString()
? `${this.baseUrl}/${tenantId}/sales/operations/aggregate/by-category?${queryParams.toString()}`
: `${this.baseUrl}/${tenantId}/sales/operations/aggregate/by-category`;
return apiClient.get<CategorySalesAnalytics[]>(url);
}
async aggregateSalesByChannel(
tenantId: string,
startDate?: string,
endDate?: string
): Promise<ChannelPerformance[]> {
const queryParams = new URLSearchParams();
if (startDate) queryParams.append('start_date', startDate);
if (endDate) queryParams.append('end_date', endDate);
const url = queryParams.toString()
? `${this.baseUrl}/${tenantId}/sales/operations/aggregate/by-channel?${queryParams.toString()}`
: `${this.baseUrl}/${tenantId}/sales/operations/aggregate/by-channel`;
return apiClient.get<ChannelPerformance[]>(url);
}
// ===================================================================
// ANALYTICS: Sales Summary
// Backend: services/sales/app/api/analytics.py
// ===================================================================
async getSalesAnalytics(
tenantId: string,
startDate?: string,
endDate?: string
): Promise<SalesAnalytics> {
const queryParams = new URLSearchParams();
if (startDate) queryParams.append('start_date', startDate);
if (endDate) queryParams.append('end_date', endDate);
const url = queryParams.toString()
? `${this.baseUrl}/${tenantId}/sales/analytics/summary?${queryParams.toString()}`
: `${this.baseUrl}/${tenantId}/sales/analytics/summary`;
return apiClient.get<SalesAnalytics>(url);
}
}
export const salesService = new SalesService();

View File

@@ -0,0 +1,152 @@
// frontend/src/api/services/settings.ts
/**
* API service for Tenant Settings
* Handles all HTTP requests for tenant operational configuration
*/
import { apiClient } from '../client/apiClient';
import type {
TenantSettings,
TenantSettingsUpdate,
SettingsCategory,
CategoryResetResponse,
} from '../types/settings';
export const settingsApi = {
/**
* Get all settings for a tenant
*/
getSettings: async (tenantId: string): Promise<TenantSettings> => {
try {
console.log('🔍 Fetching settings for tenant:', tenantId);
const response = await apiClient.get<TenantSettings>(`/tenants/${tenantId}/settings`);
console.log('📊 Settings API response data:', response);
// Validate the response data structure
if (!response) {
throw new Error('Settings response data is null or undefined');
}
if (!response.tenant_id) {
throw new Error('Settings response missing tenant_id');
}
if (!response.procurement_settings) {
throw new Error('Settings response missing procurement_settings');
}
console.log('✅ Settings data validation passed');
return response;
} catch (error) {
console.error('❌ Error fetching settings:', error);
console.error('Error details:', {
message: (error as Error).message,
stack: (error as Error).stack,
tenantId
});
throw error;
}
},
/**
* Update tenant settings (partial update supported)
*/
updateSettings: async (
tenantId: string,
updates: TenantSettingsUpdate
): Promise<TenantSettings> => {
try {
console.log('🔍 Updating settings for tenant:', tenantId, 'with updates:', updates);
const response = await apiClient.put<TenantSettings>(`/tenants/${tenantId}/settings`, updates);
console.log('📊 Settings update response:', response);
if (!response) {
throw new Error('Settings update response data is null or undefined');
}
return response;
} catch (error) {
console.error('❌ Error updating settings:', error);
throw error;
}
},
/**
* Get settings for a specific category
*/
getCategorySettings: async (
tenantId: string,
category: SettingsCategory
): Promise<Record<string, any>> => {
try {
console.log('🔍 Fetching category settings for tenant:', tenantId, 'category:', category);
const response = await apiClient.get<{ tenant_id: string; category: string; settings: Record<string, any> }>(
`/tenants/${tenantId}/settings/${category}`
);
console.log('📊 Category settings response:', response);
if (!response || !response.settings) {
throw new Error('Category settings response data is null or undefined');
}
return response.settings;
} catch (error) {
console.error('❌ Error fetching category settings:', error);
throw error;
}
},
/**
* Update settings for a specific category
*/
updateCategorySettings: async (
tenantId: string,
category: SettingsCategory,
settings: Record<string, any>
): Promise<TenantSettings> => {
try {
console.log('🔍 Updating category settings for tenant:', tenantId, 'category:', category, 'settings:', settings);
const response = await apiClient.put<TenantSettings>(
`/tenants/${tenantId}/settings/${category}`,
{ settings }
);
console.log('📊 Category settings update response:', response);
if (!response) {
throw new Error('Category settings update response data is null or undefined');
}
return response;
} catch (error) {
console.error('❌ Error updating category settings:', error);
throw error;
}
},
/**
* Reset a category to default values
*/
resetCategory: async (
tenantId: string,
category: SettingsCategory
): Promise<CategoryResetResponse> => {
try {
console.log('🔍 Resetting category for tenant:', tenantId, 'category:', category);
const response = await apiClient.post<CategoryResetResponse>(
`/tenants/${tenantId}/settings/${category}/reset`
);
console.log('📊 Category reset response:', response);
if (!response) {
throw new Error('Category reset response data is null or undefined');
}
return response;
} catch (error) {
console.error('❌ Error resetting category:', error);
throw error;
}
},
};
export default settingsApi;

View File

@@ -0,0 +1,569 @@
import { apiClient } from '../client';
import {
// New types
SubscriptionTier,
SUBSCRIPTION_TIERS,
BillingCycle,
PlanMetadata,
AvailablePlans,
UsageSummary,
FeatureCheckResponse,
QuotaCheckResponse,
PlanUpgradeValidation,
PlanUpgradeResult,
doesPlanMeetMinimum,
getPlanColor,
getYearlyDiscountPercentage,
PLAN_HIERARCHY,
// Analytics levels
ANALYTICS_LEVELS,
AnalyticsLevel,
ANALYTICS_HIERARCHY
} from '../types/subscription';
// Map plan tiers to analytics levels based on backend data
const TIER_TO_ANALYTICS_LEVEL: Record<SubscriptionTier | string, AnalyticsLevel> = {
[SUBSCRIPTION_TIERS.STARTER]: ANALYTICS_LEVELS.BASIC,
[SUBSCRIPTION_TIERS.PROFESSIONAL]: ANALYTICS_LEVELS.ADVANCED,
[SUBSCRIPTION_TIERS.ENTERPRISE]: ANALYTICS_LEVELS.PREDICTIVE,
'demo': ANALYTICS_LEVELS.ADVANCED, // Treat demo tier same as professional for analytics access
};
// Cache for available plans
let cachedPlans: AvailablePlans | null = null;
let lastFetchTime: number | null = null;
const CACHE_DURATION = 5 * 60 * 1000; // 5 minutes
export class SubscriptionService {
private readonly baseUrl = '/tenants';
private readonly plansUrl = '/plans';
// ============================================================================
// NEW METHODS - Centralized Plans API
// ============================================================================
/**
* Invalidate cached plan data
* Call this when subscription changes to ensure fresh data on next fetch
*/
invalidateCache(): void {
cachedPlans = null;
lastFetchTime = null;
}
/**
* Fetch available subscription plans with complete metadata
* Uses cached data if available and fresh (5 min cache)
*/
async fetchAvailablePlans(): Promise<AvailablePlans> {
const now = Date.now();
// Return cached data if it's still valid
if (cachedPlans && lastFetchTime && (now - lastFetchTime) < CACHE_DURATION) {
return cachedPlans;
}
try {
const plans = await apiClient.get<AvailablePlans>(this.plansUrl);
cachedPlans = plans;
lastFetchTime = now;
return plans;
} catch (error) {
console.error('Failed to fetch subscription plans:', error);
throw error;
}
}
/**
* Get metadata for a specific plan tier
*/
async getPlanMetadata(tier: SubscriptionTier): Promise<PlanMetadata | null> {
try {
const plans = await this.fetchAvailablePlans();
return plans.plans[tier] || null;
} catch (error) {
console.error('Failed to get plan metadata:', error);
return null;
}
}
/**
* Get all available features for a tier
*/
async getPlanFeatures(tier: SubscriptionTier): Promise<string[]> {
try {
const metadata = await this.getPlanMetadata(tier);
return metadata?.features || [];
} catch (error) {
console.error('Failed to get plan features:', error);
return [];
}
}
/**
* Check if a feature is available in a tier
*/
async hasFeatureInTier(tier: SubscriptionTier, featureName: string): Promise<boolean> {
try {
const features = await this.getPlanFeatures(tier);
return features.includes(featureName);
} catch (error) {
console.error('Failed to check feature availability:', error);
return false;
}
}
/**
* Get plan comparison data for pricing page
*/
async getPlanComparison(): Promise<{
tiers: SubscriptionTier[];
metadata: Record<SubscriptionTier, PlanMetadata>;
}> {
try {
const plans = await this.fetchAvailablePlans();
return {
tiers: [
SUBSCRIPTION_TIERS.STARTER,
SUBSCRIPTION_TIERS.PROFESSIONAL,
SUBSCRIPTION_TIERS.ENTERPRISE
],
metadata: plans.plans
};
} catch (error) {
console.error('Failed to get plan comparison:', error);
throw error;
}
}
/**
* Calculate savings for yearly billing
*/
calculateYearlySavings(monthlyPrice: number, yearlyPrice: number): {
savingsAmount: number;
savingsPercentage: number;
monthsFree: number;
} {
const yearlyAnnual = monthlyPrice * 12;
const savingsAmount = yearlyAnnual - yearlyPrice;
const savingsPercentage = getYearlyDiscountPercentage(monthlyPrice, yearlyPrice);
const monthsFree = Math.round(savingsAmount / monthlyPrice);
return {
savingsAmount,
savingsPercentage,
monthsFree
};
}
/**
* Check if user's plan meets minimum requirement
*/
checkPlanMeetsMinimum(userPlan: SubscriptionTier, requiredPlan: SubscriptionTier): boolean {
return doesPlanMeetMinimum(userPlan, requiredPlan);
}
/**
* Get plan display color
*/
getPlanDisplayColor(tier: SubscriptionTier): string {
return getPlanColor(tier);
}
// ============================================================================
// TENANT SUBSCRIPTION STATUS & USAGE
// ============================================================================
/**
* Get current usage summary for a tenant
*/
async getUsageSummary(tenantId: string): Promise<UsageSummary> {
return apiClient.get<UsageSummary>(`/tenants/${tenantId}/subscription/usage`);
}
/**
* Check if tenant has access to a specific feature
*/
async checkFeatureAccess(
tenantId: string,
featureName: string
): Promise<FeatureCheckResponse> {
return apiClient.get<FeatureCheckResponse>(
`/tenants/${tenantId}/subscription/features/${featureName}`
);
}
/**
* Check if tenant can perform an action within quota limits
*/
async checkQuotaLimit(
tenantId: string,
quotaType: string,
requestedAmount?: number
): Promise<QuotaCheckResponse> {
// Map quotaType to the new subscription limit endpoints
let endpoint: string;
switch (quotaType) {
case 'inventory_items':
case 'products':
endpoint = 'products';
break;
case 'users':
endpoint = 'users';
break;
case 'locations':
endpoint = 'locations';
break;
case 'recipes':
endpoint = 'recipes';
break;
case 'suppliers':
endpoint = 'suppliers';
break;
default:
throw new Error(`Unsupported quota type: ${quotaType}`);
}
const url = `/tenants/${tenantId}/subscription/limits/${endpoint}`;
// Get the response from the endpoint (returns different format than expected)
const response = await apiClient.get<{
can_add: boolean;
current_count?: number;
max_allowed?: number;
reason?: string;
message?: string;
}>(url);
// Map the response to QuotaCheckResponse format
return {
allowed: response.can_add,
current: response.current_count || 0,
limit: response.max_allowed || null,
remaining: response.max_allowed !== undefined && response.current_count !== undefined
? response.max_allowed - response.current_count
: null,
message: response.reason || response.message || ''
};
}
async validatePlanUpgrade(tenantId: string, planKey: string): Promise<PlanUpgradeValidation> {
return apiClient.get<PlanUpgradeValidation>(`/tenants/${tenantId}/subscription/validate-upgrade/${planKey}`);
}
async upgradePlan(tenantId: string, planKey: string, billingCycle: BillingCycle = 'monthly'): Promise<PlanUpgradeResult> {
// The backend expects new_plan and billing_cycle as query parameters
return apiClient.post<PlanUpgradeResult>(
`/tenants/${tenantId}/subscription/upgrade?new_plan=${planKey}&billing_cycle=${billingCycle}`,
{}
);
}
async canAddLocation(tenantId: string): Promise<{ can_add: boolean; reason?: string; current_count?: number; max_allowed?: number }> {
return apiClient.get(`/tenants/${tenantId}/subscription/limits/locations`);
}
async canAddProduct(tenantId: string): Promise<{ can_add: boolean; reason?: string; current_count?: number; max_allowed?: number }> {
return apiClient.get(`/tenants/${tenantId}/subscription/limits/products`);
}
async canAddUser(tenantId: string): Promise<{ can_add: boolean; reason?: string; current_count?: number; max_allowed?: number }> {
return apiClient.get(`/tenants/${tenantId}/subscription/limits/users`);
}
async canAddRecipe(tenantId: string): Promise<{ can_add: boolean; reason?: string; current_count?: number; max_allowed?: number }> {
return apiClient.get(`/tenants/${tenantId}/subscription/limits/recipes`);
}
async canAddSupplier(tenantId: string): Promise<{ can_add: boolean; reason?: string; current_count?: number; max_allowed?: number }> {
return apiClient.get(`/tenants/${tenantId}/subscription/limits/suppliers`);
}
async hasFeature(tenantId: string, featureName: string): Promise<{ has_feature: boolean; feature_value?: any; plan?: string; reason?: string }> {
return apiClient.get(`/tenants/${tenantId}/subscription/features/${featureName}`);
}
formatPrice(amount: number): string {
return new Intl.NumberFormat('es-ES', {
style: 'currency',
currency: 'EUR',
minimumFractionDigits: 0,
maximumFractionDigits: 2
}).format(amount);
}
/**
* Get plan display information
*/
async getPlanDisplayInfo(planKey: string) {
try {
const plans = await this.fetchAvailablePlans();
const plan = plans.plans[planKey as SubscriptionTier];
if (plan) {
return {
name: plan.name,
color: this.getPlanColor(planKey as SubscriptionTier),
description: plan.description,
monthlyPrice: plan.monthly_price
};
}
return { name: 'Desconocido', color: 'gray', description: '', monthlyPrice: 0 };
} catch (error) {
console.error('Failed to get plan display info:', error);
return { name: 'Desconocido', color: 'gray', description: '', monthlyPrice: 0 };
}
}
/**
* Get plan color based on plan key
*/
getPlanColor(planKey: string): string {
switch (planKey) {
case SUBSCRIPTION_TIERS.STARTER:
return 'blue';
case SUBSCRIPTION_TIERS.PROFESSIONAL:
return 'purple';
case SUBSCRIPTION_TIERS.ENTERPRISE:
return 'amber';
default:
return 'gray';
}
}
/**
* Get analytics level for a plan tier
*/
getAnalyticsLevelForTier(tier: SubscriptionTier): AnalyticsLevel {
return TIER_TO_ANALYTICS_LEVEL[tier] || ANALYTICS_LEVELS.NONE;
}
/**
* Get analytics level for a plan (alias for getAnalyticsLevelForTier)
* @deprecated Use getAnalyticsLevelForTier instead
*/
getAnalyticsLevelForPlan(tier: SubscriptionTier): AnalyticsLevel {
return this.getAnalyticsLevelForTier(tier);
}
/**
* Check if analytics level meets minimum requirements
*/
doesAnalyticsLevelMeetMinimum(level: AnalyticsLevel, minimumRequired: AnalyticsLevel): boolean {
return ANALYTICS_HIERARCHY[level] >= ANALYTICS_HIERARCHY[minimumRequired];
}
/**
* Cancel subscription - Downgrade to read-only mode
*/
async cancelSubscription(tenantId: string, reason?: string): Promise<{
success: boolean;
message: string;
status: string;
cancellation_effective_date: string;
days_remaining: number;
read_only_mode_starts: string;
}> {
return apiClient.post(`/tenants/${tenantId}/subscription/cancel`, {
reason: reason || ''
});
}
/**
* Reactivate a cancelled or inactive subscription
*/
async reactivateSubscription(tenantId: string, plan: string = 'starter'): Promise<{
success: boolean;
message: string;
status: string;
plan: string;
next_billing_date: string | null;
}> {
return apiClient.post(`/tenants/${tenantId}/subscription/reactivate`, {
plan
});
}
/**
* Get subscription status including read-only mode info
*/
async getSubscriptionStatus(tenantId: string): Promise<{
tenant_id: string;
status: string;
plan: string;
is_read_only: boolean;
cancellation_effective_date: string | null;
days_until_inactive: number | null;
billing_cycle?: string;
next_billing_date?: string;
}> {
return apiClient.get(`/tenants/${tenantId}/subscription/status`);
}
/**
* Get invoice history for a tenant
*/
async getInvoices(tenantId: string): Promise<Array<{
id: string;
date: string;
amount: number;
currency: string;
status: string;
description: string | null;
invoice_pdf: string | null;
hosted_invoice_url: string | null;
}>> {
return apiClient.get(`/tenants/${tenantId}/subscription/invoices`);
}
/**
* Get the current payment method for a subscription
*/
async getCurrentPaymentMethod(
tenantId: string
): Promise<{
brand: string;
last4: string;
exp_month?: number;
exp_year?: number;
} | null> {
try {
const response = await apiClient.get(`/tenants/${tenantId}/subscription/payment-method`);
return response;
} catch (error) {
console.error('Failed to get current payment method:', error);
return null;
}
}
/**
* Update the default payment method for a subscription
*/
async updatePaymentMethod(
tenantId: string,
paymentMethodId: string
): Promise<{
success: boolean;
message: string;
payment_method_id: string;
brand: string;
last4: string;
exp_month?: number;
exp_year?: number;
requires_action?: boolean;
client_secret?: string;
payment_intent_status?: string;
}> {
return apiClient.post(`/tenants/${tenantId}/subscription/payment-method`, {
payment_method_id: paymentMethodId
});
}
// ============================================================================
// NEW METHODS - Usage Forecasting & Predictive Analytics
// ============================================================================
/**
* Get usage forecast for all metrics
* Returns predictions for when tenant will hit limits based on growth rate
*/
async getUsageForecast(tenantId: string): Promise<{
tenant_id: string;
forecasted_at: string;
metrics: Array<{
metric: string;
label: string;
current: number;
limit: number | null;
unit: string;
daily_growth_rate: number | null;
predicted_breach_date: string | null;
days_until_breach: number | null;
usage_percentage: number;
status: string;
trend_data: Array<{ date: string; value: number }>;
}>;
}> {
return apiClient.get(`/usage-forecast?tenant_id=${tenantId}`);
}
/**
* Track daily usage (called by cron jobs or manually)
* Stores usage snapshots in Redis for trend analysis
*/
async trackDailyUsage(
tenantId: string,
metric: string,
value: number
): Promise<{
success: boolean;
tenant_id: string;
metric: string;
value: number;
date: string;
}> {
return apiClient.post('/usage-forecast/track-usage', {
tenant_id: tenantId,
metric,
value,
});
}
/**
* Get current subscription for a tenant
* Combines subscription data with available plans metadata
*/
async getCurrentSubscription(tenantId: string): Promise<{
tier: SubscriptionTier;
billing_cycle: 'monthly' | 'yearly';
monthly_price: number;
yearly_price: number;
renewal_date: string;
trial_ends_at?: string;
limits: {
users: number | null;
locations: number | null;
products: number | null;
recipes: number | null;
suppliers: number | null;
trainingJobsPerDay: number | null;
forecastsPerDay: number | null;
storageGB: number | null;
};
availablePlans: AvailablePlans;
}> {
// Fetch both subscription status and available plans
const [status, plans] = await Promise.all([
this.getSubscriptionStatus(tenantId),
this.fetchAvailablePlans(),
]);
const currentPlan = plans.plans[status.plan as SubscriptionTier];
return {
tier: status.plan as SubscriptionTier,
billing_cycle: (status.billing_cycle as 'monthly' | 'yearly') || 'monthly',
monthly_price: currentPlan?.monthly_price || 0,
yearly_price: currentPlan?.yearly_price || 0,
renewal_date: status.next_billing_date || new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(),
limits: {
users: currentPlan?.limits?.users ?? null,
locations: currentPlan?.limits?.locations ?? null,
products: currentPlan?.limits?.products ?? null,
recipes: currentPlan?.limits?.recipes ?? null,
suppliers: currentPlan?.limits?.suppliers ?? null,
trainingJobsPerDay: currentPlan?.limits?.training_jobs_per_day ?? null,
forecastsPerDay: currentPlan?.limits?.forecasts_per_day ?? null,
storageGB: currentPlan?.limits?.storage_gb ?? null,
},
availablePlans: plans,
};
}
}
export const subscriptionService = new SubscriptionService();

View File

@@ -0,0 +1,478 @@
// ================================================================
// frontend/src/api/services/suppliers.ts
// ================================================================
/**
* Suppliers Service - Complete backend alignment
*
* Backend API structure (3-tier architecture):
* - ATOMIC: suppliers.py, purchase_orders.py, deliveries.py
* - OPERATIONS: supplier_operations.py (approval, statistics, performance)
* - ANALYTICS: analytics.py (performance metrics, alerts)
*
* Last Updated: 2025-10-05
* Status: ✅ Complete - Zero drift with backend
*/
import { apiClient } from '../client/apiClient';
import type {
SupplierCreate,
SupplierUpdate,
SupplierResponse,
SupplierSummary,
SupplierApproval,
SupplierSearchParams,
SupplierStatistics,
SupplierDeletionSummary,
SupplierResponse as SupplierResponse_,
DeliveryCreate,
DeliveryUpdate,
DeliveryResponse,
DeliveryReceiptConfirmation,
DeliverySearchParams,
PerformanceMetric,
PerformanceAlert,
SupplierPriceListCreate,
SupplierPriceListUpdate,
SupplierPriceListResponse
} from '../types/suppliers';
class SuppliersService {
private readonly baseUrl = '/tenants';
// ===================================================================
// ATOMIC: Suppliers CRUD
// Backend: services/suppliers/app/api/suppliers.py
// ===================================================================
async createSupplier(
tenantId: string,
supplierData: SupplierCreate
): Promise<SupplierResponse> {
return apiClient.post<SupplierResponse>(
`${this.baseUrl}/${tenantId}/suppliers`,
supplierData
);
}
// ===================================================================
// ATOMIC: Supplier Price Lists CRUD
// Backend: services/suppliers/app/api/suppliers.py (price list endpoints)
// ===================================================================
async getSupplierPriceLists(
tenantId: string,
supplierId: string,
isActive: boolean = true
): Promise<SupplierPriceListResponse[]> {
const params = new URLSearchParams();
params.append('is_active', isActive.toString());
return apiClient.get<SupplierPriceListResponse[]>(
`${this.baseUrl}/${tenantId}/suppliers/${supplierId}/price-lists?${params.toString()}`
);
}
async getSupplierPriceList(
tenantId: string,
supplierId: string,
priceListId: string
): Promise<SupplierPriceListResponse> {
return apiClient.get<SupplierPriceListResponse>(
`${this.baseUrl}/${tenantId}/suppliers/${supplierId}/price-lists/${priceListId}`
);
}
async createSupplierPriceList(
tenantId: string,
supplierId: string,
priceListData: SupplierPriceListCreate
): Promise<SupplierPriceListResponse> {
return apiClient.post<SupplierPriceListResponse>(
`${this.baseUrl}/${tenantId}/suppliers/${supplierId}/price-lists`,
priceListData
);
}
async updateSupplierPriceList(
tenantId: string,
supplierId: string,
priceListId: string,
priceListData: SupplierPriceListUpdate
): Promise<SupplierPriceListResponse> {
return apiClient.put<SupplierPriceListResponse>(
`${this.baseUrl}/${tenantId}/suppliers/${supplierId}/price-lists/${priceListId}`,
priceListData
);
}
async deleteSupplierPriceList(
tenantId: string,
supplierId: string,
priceListId: string
): Promise<{ message: string }> {
return apiClient.delete<{ message: string }>(
`${this.baseUrl}/${tenantId}/suppliers/${supplierId}/price-lists/${priceListId}`
);
}
async getSuppliers(
tenantId: string,
queryParams?: SupplierSearchParams
): Promise<SupplierSummary[]> {
const params = new URLSearchParams();
if (queryParams?.search_term) params.append('search_term', queryParams.search_term);
if (queryParams?.supplier_type) params.append('supplier_type', queryParams.supplier_type);
if (queryParams?.status) params.append('status', queryParams.status);
if (queryParams?.limit) params.append('limit', queryParams.limit.toString());
if (queryParams?.offset) params.append('offset', queryParams.offset.toString());
const queryString = params.toString() ? `?${params.toString()}` : '';
return apiClient.get<SupplierSummary[]>(
`${this.baseUrl}/${tenantId}/suppliers${queryString}`
);
}
async getSupplier(tenantId: string, supplierId: string): Promise<SupplierResponse> {
return apiClient.get<SupplierResponse>(
`${this.baseUrl}/${tenantId}/suppliers/${supplierId}`
);
}
async updateSupplier(
tenantId: string,
supplierId: string,
updateData: SupplierUpdate
): Promise<SupplierResponse> {
return apiClient.put<SupplierResponse>(
`${this.baseUrl}/${tenantId}/suppliers/${supplierId}`,
updateData
);
}
async deleteSupplier(
tenantId: string,
supplierId: string
): Promise<{ message: string }> {
return apiClient.delete<{ message: string }>(
`${this.baseUrl}/${tenantId}/suppliers/${supplierId}`
);
}
async hardDeleteSupplier(
tenantId: string,
supplierId: string
): Promise<SupplierDeletionSummary> {
return apiClient.delete<SupplierDeletionSummary>(
`${this.baseUrl}/${tenantId}/suppliers/${supplierId}/hard`
);
}
async getSupplierProducts(
tenantId: string,
supplierId: string,
isActive: boolean = true
): Promise<Array<{ inventory_product_id: string }>> {
const params = new URLSearchParams();
params.append('is_active', isActive.toString());
return apiClient.get<Array<{ inventory_product_id: string }>>(
`${this.baseUrl}/${tenantId}/suppliers/${supplierId}/products?${params.toString()}`
);
}
// ===================================================================
// ATOMIC: Purchase Orders CRUD
// Backend: services/suppliers/app/api/purchase_orders.py
// ===================================================================
async createPurchaseOrder(
tenantId: string,
orderData: PurchaseOrderCreate
): Promise<PurchaseOrderResponse> {
return apiClient.post<PurchaseOrderResponse>(
`${this.baseUrl}/${tenantId}/suppliers/purchase-orders`,
orderData
);
}
async getPurchaseOrders(
tenantId: string,
queryParams?: PurchaseOrderSearchParams
): Promise<PurchaseOrderResponse[]> {
const params = new URLSearchParams();
if (queryParams?.supplier_id) params.append('supplier_id', queryParams.supplier_id);
if (queryParams?.status) params.append('status', queryParams.status);
if (queryParams?.priority) params.append('priority', queryParams.priority);
if (queryParams?.date_from) params.append('date_from', queryParams.date_from);
if (queryParams?.date_to) params.append('date_to', queryParams.date_to);
if (queryParams?.limit) params.append('limit', queryParams.limit.toString());
if (queryParams?.offset) params.append('offset', queryParams.offset.toString());
const queryString = params.toString() ? `?${params.toString()}` : '';
return apiClient.get<PurchaseOrderResponse[]>(
`${this.baseUrl}/${tenantId}/suppliers/purchase-orders${queryString}`
);
}
async getPurchaseOrder(tenantId: string, orderId: string): Promise<PurchaseOrderResponse> {
return apiClient.get<PurchaseOrderResponse>(
`${this.baseUrl}/${tenantId}/suppliers/purchase-orders/${orderId}`
);
}
async updatePurchaseOrder(
tenantId: string,
orderId: string,
updateData: PurchaseOrderUpdate
): Promise<PurchaseOrderResponse> {
return apiClient.put<PurchaseOrderResponse>(
`${this.baseUrl}/${tenantId}/suppliers/purchase-orders/${orderId}`,
updateData
);
}
async approvePurchaseOrder(
tenantId: string,
orderId: string,
approval: PurchaseOrderApproval
): Promise<PurchaseOrderResponse> {
return apiClient.post<PurchaseOrderResponse>(
`${this.baseUrl}/${tenantId}/suppliers/purchase-orders/${orderId}/approve`,
approval
);
}
// ===================================================================
// ATOMIC: Deliveries CRUD
// Backend: services/suppliers/app/api/deliveries.py
// ===================================================================
async createDelivery(
tenantId: string,
deliveryData: DeliveryCreate
): Promise<DeliveryResponse> {
return apiClient.post<DeliveryResponse>(
`${this.baseUrl}/${tenantId}/suppliers/deliveries`,
deliveryData
);
}
async getDeliveries(
tenantId: string,
queryParams?: DeliverySearchParams
): Promise<DeliveryResponse[]> {
const params = new URLSearchParams();
if (queryParams?.supplier_id) params.append('supplier_id', queryParams.supplier_id);
if (queryParams?.purchase_order_id) {
params.append('purchase_order_id', queryParams.purchase_order_id);
}
if (queryParams?.status) params.append('status', queryParams.status);
if (queryParams?.scheduled_date_from) {
params.append('scheduled_date_from', queryParams.scheduled_date_from);
}
if (queryParams?.scheduled_date_to) {
params.append('scheduled_date_to', queryParams.scheduled_date_to);
}
if (queryParams?.limit) params.append('limit', queryParams.limit.toString());
if (queryParams?.offset) params.append('offset', queryParams.offset.toString());
const queryString = params.toString() ? `?${params.toString()}` : '';
return apiClient.get<DeliveryResponse[]>(
`${this.baseUrl}/${tenantId}/suppliers/deliveries${queryString}`
);
}
async getDelivery(tenantId: string, deliveryId: string): Promise<DeliveryResponse> {
return apiClient.get<DeliveryResponse>(
`${this.baseUrl}/${tenantId}/suppliers/deliveries/${deliveryId}`
);
}
async updateDelivery(
tenantId: string,
deliveryId: string,
updateData: DeliveryUpdate
): Promise<DeliveryResponse> {
return apiClient.put<DeliveryResponse>(
`${this.baseUrl}/${tenantId}/suppliers/deliveries/${deliveryId}`,
updateData
);
}
async confirmDeliveryReceipt(
tenantId: string,
deliveryId: string,
confirmation: DeliveryReceiptConfirmation
): Promise<DeliveryResponse> {
return apiClient.post<DeliveryResponse>(
`${this.baseUrl}/${tenantId}/suppliers/deliveries/${deliveryId}/confirm-receipt`,
confirmation
);
}
// ===================================================================
// OPERATIONS: Supplier Management
// Backend: services/suppliers/app/api/supplier_operations.py
// ===================================================================
async getSupplierStatistics(tenantId: string): Promise<SupplierStatistics> {
return apiClient.get<SupplierStatistics>(
`${this.baseUrl}/${tenantId}/suppliers/operations/statistics`
);
}
async getActiveSuppliers(
tenantId: string,
queryParams?: SupplierSearchParams
): Promise<SupplierSummary[]> {
const params = new URLSearchParams();
if (queryParams?.search_term) params.append('search_term', queryParams.search_term);
if (queryParams?.supplier_type) params.append('supplier_type', queryParams.supplier_type);
if (queryParams?.limit) params.append('limit', queryParams.limit.toString());
if (queryParams?.offset) params.append('offset', queryParams.offset.toString());
const queryString = params.toString() ? `?${params.toString()}` : '';
return apiClient.get<SupplierSummary[]>(
`${this.baseUrl}/${tenantId}/suppliers/operations/active${queryString}`
);
}
async getTopSuppliers(tenantId: string): Promise<TopSuppliersResponse> {
return apiClient.get<TopSuppliersResponse>(
`${this.baseUrl}/${tenantId}/suppliers/operations/top`
);
}
async getPendingApprovalSuppliers(
tenantId: string
): Promise<PaginatedResponse<SupplierSummary>> {
return apiClient.get<PaginatedResponse<SupplierSummary>>(
`${this.baseUrl}/${tenantId}/suppliers/operations/pending-review`
);
}
async getSuppliersByType(
tenantId: string,
supplierType: string,
queryParams?: Omit<SupplierQueryParams, 'supplier_type'>
): Promise<PaginatedResponse<SupplierSummary>> {
const params = new URLSearchParams();
if (queryParams?.search_term) params.append('search_term', queryParams.search_term);
if (queryParams?.status) params.append('status', queryParams.status);
if (queryParams?.limit) params.append('limit', queryParams.limit.toString());
if (queryParams?.offset) params.append('offset', queryParams.offset.toString());
const queryString = params.toString() ? `?${params.toString()}` : '';
return apiClient.get<PaginatedResponse<SupplierSummary>>(
`${this.baseUrl}/${tenantId}/suppliers/types/${supplierType}${queryString}`
);
}
async approveSupplier(
tenantId: string,
supplierId: string,
approval: SupplierApproval
): Promise<SupplierResponse> {
return apiClient.post<SupplierResponse>(
`${this.baseUrl}/${tenantId}/suppliers/${supplierId}/approve`,
approval
);
}
// ===================================================================
// ANALYTICS: Performance Metrics
// Backend: services/suppliers/app/api/analytics.py
// ===================================================================
async calculateSupplierPerformance(
tenantId: string,
supplierId: string,
request?: PerformanceCalculationRequest
): Promise<{ message: string; calculation_id: string }> {
const params = new URLSearchParams();
if (request?.period) params.append('period', request.period);
if (request?.period_start) params.append('period_start', request.period_start);
if (request?.period_end) params.append('period_end', request.period_end);
const queryString = params.toString() ? `?${params.toString()}` : '';
return apiClient.post<{ message: string; calculation_id: string }>(
`${this.baseUrl}/${tenantId}/suppliers/analytics/performance/${supplierId}/calculate${queryString}`
);
}
async getSupplierPerformanceMetrics(
tenantId: string,
supplierId: string
): Promise<PerformanceMetric[]> {
return apiClient.get<PerformanceMetric[]>(
`${this.baseUrl}/${tenantId}/suppliers/analytics/performance/${supplierId}/metrics`
);
}
async evaluatePerformanceAlerts(
tenantId: string
): Promise<{ alerts_generated: number; message: string }> {
return apiClient.post<{ alerts_generated: number; message: string }>(
`${this.baseUrl}/${tenantId}/suppliers/analytics/performance/alerts/evaluate`
);
}
async getPerformanceAlerts(
tenantId: string,
supplierId?: string
): Promise<PerformanceAlert[]> {
const url = supplierId
? `${this.baseUrl}/${tenantId}/suppliers/analytics/performance/${supplierId}/alerts`
: `${this.baseUrl}/${tenantId}/suppliers/analytics/performance/alerts`;
return apiClient.get<PerformanceAlert[]>(url);
}
// ===================================================================
// UTILITY METHODS (Client-side helpers)
// ===================================================================
calculateOrderTotal(
items: { ordered_quantity: number; unit_price: number }[],
taxAmount: number = 0,
shippingCost: number = 0,
discountAmount: number = 0
): number {
const subtotal = items.reduce(
(sum, item) => sum + (item.ordered_quantity * item.unit_price),
0
);
return subtotal + taxAmount + shippingCost - discountAmount;
}
formatSupplierCode(name: string, sequence?: number): string {
const cleanName = name.replace(/[^a-zA-Z0-9]/g, '').toUpperCase();
const prefix = cleanName.substring(0, 3).padEnd(3, 'X');
const suffix = sequence ? sequence.toString().padStart(3, '0') : '001';
return `${prefix}${suffix}`;
}
validateTaxId(taxId: string, country: string = 'ES'): boolean {
// Simplified validation - real implementation would have proper country-specific validation
if (country === 'ES') {
// Spanish VAT format: ES + letter + 8 digits or ES + 8 digits + letter
const spanishVatRegex = /^ES[A-Z]\d{8}$|^ES\d{8}[A-Z]$/;
return spanishVatRegex.test(taxId.toUpperCase());
}
return taxId.length > 0;
}
formatCurrency(amount: number, currency: string = 'EUR'): string {
return new Intl.NumberFormat('es-ES', {
style: 'currency',
currency: currency,
}).format(amount);
}
}
// Create and export singleton instance
export const suppliersService = new SuppliersService();
export default suppliersService;

View File

@@ -0,0 +1,617 @@
/**
* Sustainability API Service - Microservices Architecture
* Fetches data from production and inventory services in parallel
* Performs client-side aggregation of sustainability metrics
*/
import apiClient from '../client/apiClient';
import type {
SustainabilityMetrics,
SustainabilityWidgetData,
SDGCompliance,
EnvironmentalImpact,
GrantReport,
WasteMetrics,
FinancialImpact,
AvoidedWaste,
GrantReadiness
} from '../types/sustainability';
// ===== SERVICE-SPECIFIC API CALLS =====
/**
* Production Service: Get production waste metrics
*/
export async function getProductionWasteMetrics(
tenantId: string,
startDate?: string,
endDate?: string
): Promise<any> {
const params = new URLSearchParams();
if (startDate) params.append('start_date', startDate);
if (endDate) params.append('end_date', endDate);
const queryString = params.toString();
const url = `/tenants/${tenantId}/production/sustainability/waste-metrics${queryString ? `?${queryString}` : ''}`;
return await apiClient.get<any>(url);
}
/**
* Production Service: Get production baseline metrics
*/
export async function getProductionBaseline(
tenantId: string,
startDate?: string,
endDate?: string
): Promise<any> {
const params = new URLSearchParams();
if (startDate) params.append('start_date', startDate);
if (endDate) params.append('end_date', endDate);
const queryString = params.toString();
const url = `/tenants/${tenantId}/production/sustainability/baseline${queryString ? `?${queryString}` : ''}`;
return await apiClient.get<any>(url);
}
/**
* Production Service: Get AI impact on waste reduction
*/
export async function getProductionAIImpact(
tenantId: string,
startDate?: string,
endDate?: string
): Promise<any> {
const params = new URLSearchParams();
if (startDate) params.append('start_date', startDate);
if (endDate) params.append('end_date', endDate);
const queryString = params.toString();
const url = `/tenants/${tenantId}/production/sustainability/ai-impact${queryString ? `?${queryString}` : ''}`;
return await apiClient.get<any>(url);
}
/**
* Production Service: Get summary widget data
*/
export async function getProductionSummary(
tenantId: string,
days: number = 30
): Promise<any> {
return await apiClient.get<any>(
`/tenants/${tenantId}/production/sustainability/summary?days=${days}`
);
}
/**
* Inventory Service: Get inventory waste metrics
*/
export async function getInventoryWasteMetrics(
tenantId: string,
startDate?: string,
endDate?: string
): Promise<any> {
const params = new URLSearchParams();
if (startDate) params.append('start_date', startDate);
if (endDate) params.append('end_date', endDate);
const queryString = params.toString();
const url = `/tenants/${tenantId}/inventory/sustainability/waste-metrics${queryString ? `?${queryString}` : ''}`;
return await apiClient.get<any>(url);
}
/**
* Inventory Service: Get expiry alerts
*/
export async function getInventoryExpiryAlerts(
tenantId: string,
daysAhead: number = 7
): Promise<any> {
return await apiClient.get<any>(
`/tenants/${tenantId}/inventory/sustainability/expiry-alerts?days_ahead=${daysAhead}`
);
}
/**
* Inventory Service: Get waste events
*/
export async function getInventoryWasteEvents(
tenantId: string,
limit: number = 50,
offset: number = 0,
startDate?: string,
endDate?: string,
reasonCode?: string
): Promise<any> {
const params = new URLSearchParams();
params.append('limit', limit.toString());
params.append('offset', offset.toString());
if (startDate) params.append('start_date', startDate);
if (endDate) params.append('end_date', endDate);
if (reasonCode) params.append('reason_code', reasonCode);
const queryString = params.toString();
const url = `/tenants/${tenantId}/inventory/sustainability/waste-events?${queryString}`;
return await apiClient.get<any>(url);
}
/**
* Inventory Service: Get summary widget data
*/
export async function getInventorySummary(
tenantId: string,
days: number = 30
): Promise<any> {
return await apiClient.get<any>(
`/tenants/${tenantId}/inventory/sustainability/summary?days=${days}`
);
}
// ===== AGGREGATION FUNCTIONS =====
/**
* Environmental Constants for calculations
*/
const EnvironmentalConstants = {
CO2_PER_KG_WASTE: 1.9, // kg CO2e per kg waste
WATER_PER_KG: 1500, // liters per kg
LAND_USE_PER_KG: 3.4, // m² per kg
TREES_PER_TON_CO2: 50,
SDG_TARGET_REDUCTION: 0.50, // 50% reduction target
EU_BAKERY_BASELINE_WASTE: 0.25, // 25% baseline
MINIMUM_PRODUCTION_KG: 50 // Minimum production to show meaningful metrics
};
/**
* Calculate environmental impact from total waste
*/
function calculateEnvironmentalImpact(totalWasteKg: number): EnvironmentalImpact {
const co2Kg = totalWasteKg * EnvironmentalConstants.CO2_PER_KG_WASTE;
const co2Tons = co2Kg / 1000;
const waterLiters = totalWasteKg * EnvironmentalConstants.WATER_PER_KG;
const landSqMeters = totalWasteKg * EnvironmentalConstants.LAND_USE_PER_KG;
return {
co2_emissions: {
kg: Math.round(co2Kg * 100) / 100,
tons: Math.round(co2Tons * 1000) / 1000,
trees_to_offset: Math.ceil(co2Tons * EnvironmentalConstants.TREES_PER_TON_CO2)
},
water_footprint: {
liters: Math.round(waterLiters),
cubic_meters: Math.round(waterLiters / 1000 * 100) / 100
},
land_use: {
square_meters: Math.round(landSqMeters * 100) / 100,
hectares: Math.round(landSqMeters / 10000 * 1000) / 1000
},
human_equivalents: {
car_km_equivalent: Math.round(co2Kg / 0.120), // 120g CO2 per km
smartphone_charges: Math.round(co2Kg * 1000 / 8), // 8g CO2 per charge
showers_equivalent: Math.round(waterLiters / 65), // 65L per shower
trees_planted: Math.ceil(co2Tons * EnvironmentalConstants.TREES_PER_TON_CO2)
}
};
}
/**
* Calculate SDG compliance status
*/
function calculateSDGCompliance(
currentWastePercentage: number,
baselineWastePercentage: number
): SDGCompliance {
const reductionAchieved = baselineWastePercentage > 0
? ((baselineWastePercentage - currentWastePercentage) / baselineWastePercentage) * 100
: 0;
const targetReduction = EnvironmentalConstants.SDG_TARGET_REDUCTION * 100; // 50%
const progressToTarget = Math.max(0, Math.min(100, (reductionAchieved / targetReduction) * 100));
let status: 'sdg_compliant' | 'on_track' | 'progressing' | 'baseline' | 'above_baseline' = 'baseline';
let statusLabel = 'Establishing Baseline';
if (reductionAchieved >= targetReduction) {
status = 'sdg_compliant';
statusLabel = 'SDG Compliant';
} else if (reductionAchieved >= 30) {
status = 'on_track';
statusLabel = 'On Track';
} else if (reductionAchieved >= 10) {
status = 'progressing';
statusLabel = 'Progressing';
} else if (reductionAchieved > 0) {
status = 'baseline';
statusLabel = 'Improving from Baseline';
} else if (reductionAchieved < 0) {
status = 'above_baseline';
statusLabel = 'Above Baseline';
}
return {
sdg_12_3: {
baseline_waste_percentage: Math.round(baselineWastePercentage * 100) / 100,
current_waste_percentage: Math.round(currentWastePercentage * 100) / 100,
reduction_achieved: Math.round(reductionAchieved * 100) / 100,
target_reduction: targetReduction,
progress_to_target: Math.round(progressToTarget * 100) / 100,
status,
status_label: statusLabel,
target_waste_percentage: baselineWastePercentage * (1 - EnvironmentalConstants.SDG_TARGET_REDUCTION)
},
baseline_period: 'first_90_days',
certification_ready: status === 'sdg_compliant',
improvement_areas: status === 'sdg_compliant' ? [] : ['reduce_waste_further']
};
}
/**
* Assess grant readiness based on SDG compliance
*/
function assessGrantReadiness(sdgCompliance: SDGCompliance): GrantReadiness {
const reductionAchieved = sdgCompliance.sdg_12_3.reduction_achieved;
const isSdgCompliant = sdgCompliance.certification_ready;
const grantPrograms: Record<string, any> = {
life_circular_economy: {
eligible: reductionAchieved >= 30,
confidence: reductionAchieved >= 40 ? 'high' : reductionAchieved >= 30 ? 'medium' : 'low',
requirements_met: reductionAchieved >= 30,
funding_eur: 73_000_000,
deadline: '2025-09-23',
program_type: 'grant'
},
horizon_europe_cluster_6: {
eligible: isSdgCompliant,
confidence: isSdgCompliant ? 'high' : 'low',
requirements_met: isSdgCompliant,
funding_eur: 880_000_000,
deadline: 'rolling_2025',
program_type: 'grant'
},
fedima_sustainability_grant: {
eligible: reductionAchieved >= 20,
confidence: reductionAchieved >= 25 ? 'high' : reductionAchieved >= 20 ? 'medium' : 'low',
requirements_met: reductionAchieved >= 20,
funding_eur: 20_000,
deadline: '2025-06-30',
program_type: 'grant',
sector_specific: 'bakery'
},
eit_food_retail: {
eligible: reductionAchieved >= 15,
confidence: reductionAchieved >= 20 ? 'high' : reductionAchieved >= 15 ? 'medium' : 'low',
requirements_met: reductionAchieved >= 15,
funding_eur: 45_000,
deadline: 'rolling',
program_type: 'grant',
sector_specific: 'retail'
},
un_sdg_certified: {
eligible: isSdgCompliant,
confidence: isSdgCompliant ? 'high' : 'low',
requirements_met: isSdgCompliant,
funding_eur: 0,
deadline: 'ongoing',
program_type: 'certification'
}
};
const recommendedApplications = Object.entries(grantPrograms)
.filter(([_, program]) => program.eligible && program.confidence !== 'low')
.map(([name, _]) => name);
const eligibleCount = Object.values(grantPrograms).filter(p => p.eligible).length;
const overallReadiness = (eligibleCount / Object.keys(grantPrograms).length) * 100;
return {
overall_readiness_percentage: Math.round(overallReadiness),
grant_programs: grantPrograms,
recommended_applications: recommendedApplications,
spain_compliance: {
law_1_2025: reductionAchieved >= 50,
circular_economy_strategy: reductionAchieved >= 30
}
};
}
// ===== MAIN AGGREGATION FUNCTION =====
/**
* Get default metrics for insufficient data state
*/
function getInsufficientDataMetrics(
totalProductionKg: number,
startDate?: string,
endDate?: string
): SustainabilityMetrics {
return {
period: {
start_date: startDate || new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString(),
end_date: endDate || new Date().toISOString(),
days: 30
},
waste_metrics: {
total_waste_kg: 0,
production_waste_kg: 0,
expired_waste_kg: 0,
waste_percentage: 0,
waste_by_reason: {}
},
environmental_impact: {
co2_emissions: { kg: 0, tons: 0, trees_to_offset: 0 },
water_footprint: { liters: 0, cubic_meters: 0 },
land_use: { square_meters: 0, hectares: 0 },
human_equivalents: { car_km_equivalent: 0, smartphone_charges: 0, showers_equivalent: 0, trees_planted: 0 }
},
sdg_compliance: {
sdg_12_3: {
baseline_waste_percentage: 0,
current_waste_percentage: 0,
reduction_achieved: 0,
target_reduction: 50,
progress_to_target: 0,
status: 'baseline',
status_label: 'Collecting Baseline Data',
target_waste_percentage: 0
},
baseline_period: 'not_available',
certification_ready: false,
improvement_areas: ['start_production_tracking']
},
avoided_waste: {
waste_avoided_kg: 0,
ai_assisted_batches: 0,
environmental_impact_avoided: { co2_kg: 0, water_liters: 0 },
methodology: 'insufficient_data'
},
financial_impact: {
waste_cost_eur: 0,
cost_per_kg: 3.50,
potential_monthly_savings: 0,
annual_projection: 0
},
grant_readiness: {
overall_readiness_percentage: 0,
grant_programs: {
life_circular_economy: { eligible: false, confidence: 'low', requirements_met: false, funding_eur: 73_000_000 },
horizon_europe_cluster_6: { eligible: false, confidence: 'low', requirements_met: false, funding_eur: 880_000_000 },
fedima_sustainability_grant: { eligible: false, confidence: 'low', requirements_met: false, funding_eur: 20_000 },
eit_food_retail: { eligible: false, confidence: 'low', requirements_met: false, funding_eur: 45_000 },
un_sdg_certified: { eligible: false, confidence: 'low', requirements_met: false, funding_eur: 0 }
},
recommended_applications: [],
spain_compliance: { law_1_2025: false, circular_economy_strategy: false }
},
data_sufficient: false,
minimum_production_required_kg: EnvironmentalConstants.MINIMUM_PRODUCTION_KG,
current_production_kg: totalProductionKg
};
}
/**
* Get comprehensive sustainability metrics by aggregating production and inventory data
*/
export async function getSustainabilityMetrics(
tenantId: string,
startDate?: string,
endDate?: string
): Promise<SustainabilityMetrics> {
try {
// Fetch data from both services in parallel
const [productionData, inventoryData, productionBaseline, aiImpact] = await Promise.all([
getProductionWasteMetrics(tenantId, startDate, endDate),
getInventoryWasteMetrics(tenantId, startDate, endDate),
getProductionBaseline(tenantId, startDate, endDate),
getProductionAIImpact(tenantId, startDate, endDate)
]);
// Calculate total production
const totalProductionKg = productionData.total_planned || 0;
// Check if we have sufficient data for meaningful metrics
// Minimum: 50kg production to avoid false metrics on empty accounts
const hasDataSufficient = totalProductionKg >= EnvironmentalConstants.MINIMUM_PRODUCTION_KG;
// If insufficient data, return a "collecting data" state
if (!hasDataSufficient) {
return getInsufficientDataMetrics(totalProductionKg, startDate, endDate);
}
// Aggregate waste metrics
const productionWaste = (productionData.total_production_waste || 0) + (productionData.total_defects || 0);
const totalWasteKg = productionWaste + (inventoryData.inventory_waste_kg || 0);
const wastePercentage = totalProductionKg > 0
? (totalWasteKg / totalProductionKg) * 100
: 0;
const wasteMetrics: WasteMetrics = {
total_waste_kg: Math.round(totalWasteKg * 100) / 100,
production_waste_kg: Math.round(productionWaste * 100) / 100,
expired_waste_kg: Math.round((inventoryData.inventory_waste_kg || 0) * 100) / 100,
waste_percentage: Math.round(wastePercentage * 100) / 100,
waste_by_reason: {
...(productionData.waste_by_defect_type || {}),
...(inventoryData.waste_by_reason || {})
}
};
// Calculate environmental impact
const environmentalImpact = calculateEnvironmentalImpact(totalWasteKg);
// Calculate SDG compliance
const baselineWastePercentage = productionBaseline.waste_percentage ||
EnvironmentalConstants.EU_BAKERY_BASELINE_WASTE * 100;
const sdgCompliance = calculateSDGCompliance(wastePercentage, baselineWastePercentage);
// Calculate avoided waste from AI
const wasteAvoidedKg = aiImpact.impact?.waste_avoided_kg || 0;
const avoidedWaste: AvoidedWaste = {
waste_avoided_kg: Math.round(wasteAvoidedKg * 100) / 100,
ai_assisted_batches: aiImpact.ai_batches?.count || 0,
environmental_impact_avoided: {
co2_kg: Math.round(wasteAvoidedKg * EnvironmentalConstants.CO2_PER_KG_WASTE * 100) / 100,
water_liters: Math.round(wasteAvoidedKg * EnvironmentalConstants.WATER_PER_KG)
},
methodology: 'ai_vs_manual_comparison'
};
// Calculate financial impact
const inventoryCost = inventoryData.waste_cost_eur || 0;
const productionCost = productionWaste * 3.50; // €3.50/kg avg
const totalCost = inventoryCost + productionCost;
const financialImpact: FinancialImpact = {
waste_cost_eur: Math.round(totalCost * 100) / 100,
cost_per_kg: 3.50,
potential_monthly_savings: Math.round((aiImpact.impact?.cost_savings_eur || 0) * 100) / 100,
annual_projection: Math.round((aiImpact.impact?.cost_savings_eur || 0) * 12 * 100) / 100
};
// Assess grant readiness
const grantReadiness = assessGrantReadiness(sdgCompliance);
return {
period: productionData.period || {
start_date: startDate || new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString(),
end_date: endDate || new Date().toISOString(),
days: 30
},
waste_metrics: wasteMetrics,
environmental_impact: environmentalImpact,
sdg_compliance: sdgCompliance,
avoided_waste: avoidedWaste,
financial_impact: financialImpact,
grant_readiness: grantReadiness,
data_sufficient: true,
minimum_production_required_kg: EnvironmentalConstants.MINIMUM_PRODUCTION_KG,
current_production_kg: totalProductionKg
};
} catch (error) {
console.error('Error aggregating sustainability metrics:', error);
throw error;
}
}
/**
* Get simplified sustainability widget data
*/
export async function getSustainabilityWidgetData(
tenantId: string,
days: number = 30
): Promise<SustainabilityWidgetData> {
try {
// Fetch summaries from both services in parallel
const [productionSummary, inventorySummary] = await Promise.all([
getProductionSummary(tenantId, days),
getInventorySummary(tenantId, days)
]);
const productionWasteWidget = (productionSummary.total_production_waste || 0) + (productionSummary.total_defects || 0);
const totalWasteKg = productionWasteWidget + (inventorySummary.inventory_waste_kg || 0);
const totalProduction = productionSummary.total_planned || productionSummary.total_production_kg || 0;
const wastePercentage = totalProduction > 0 ? ((totalWasteKg / totalProduction) * 100) : 0;
const baselinePercentage = productionSummary.waste_percentage || 25;
const reductionPercentage = baselinePercentage > 0
? ((baselinePercentage - wastePercentage) / baselinePercentage) * 100
: 0;
const co2SavedKg = totalWasteKg * EnvironmentalConstants.CO2_PER_KG_WASTE;
const waterSavedLiters = totalWasteKg * EnvironmentalConstants.WATER_PER_KG;
return {
total_waste_kg: Math.round(totalWasteKg * 100) / 100,
waste_reduction_percentage: Math.round(reductionPercentage * 100) / 100,
co2_saved_kg: Math.round(co2SavedKg * 100) / 100,
water_saved_liters: Math.round(waterSavedLiters),
trees_equivalent: Math.ceil((co2SavedKg / 1000) * EnvironmentalConstants.TREES_PER_TON_CO2),
sdg_status: reductionPercentage >= 50 ? 'sdg_compliant' :
reductionPercentage >= 37.5 ? 'on_track' :
reductionPercentage >= 12.5 ? 'progressing' : 'baseline',
sdg_progress: Math.min(100, (reductionPercentage / 50) * 100),
grant_programs_ready: reductionPercentage >= 50 ? 5 :
reductionPercentage >= 30 ? 3 :
reductionPercentage >= 15 ? 2 : 0,
financial_savings_eur: Math.round(
((inventorySummary.waste_cost_eur || 0) + (productionWasteWidget * 3.50)) * 100
) / 100
};
} catch (error) {
console.error('Error fetching sustainability widget data:', error);
throw error;
}
}
/**
* Get SDG 12.3 compliance status
*/
export async function getSDGCompliance(tenantId: string): Promise<SDGCompliance> {
const metrics = await getSustainabilityMetrics(tenantId);
return metrics.sdg_compliance;
}
/**
* Get environmental impact metrics
*/
export async function getEnvironmentalImpact(
tenantId: string,
days: number = 30
): Promise<EnvironmentalImpact> {
const endDate = new Date().toISOString();
const startDate = new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString();
const metrics = await getSustainabilityMetrics(tenantId, startDate, endDate);
return metrics.environmental_impact;
}
/**
* Export grant application report
* Note: This still uses the aggregated metrics approach
*/
export async function exportGrantReport(
tenantId: string,
grantType: string = 'general',
startDate?: string,
endDate?: string
): Promise<GrantReport> {
const metrics = await getSustainabilityMetrics(tenantId, startDate, endDate);
return {
report_metadata: {
generated_at: new Date().toISOString(),
report_type: grantType,
period: metrics.period,
tenant_id: tenantId
},
executive_summary: {
total_waste_reduced_kg: metrics.avoided_waste.waste_avoided_kg,
waste_reduction_percentage: metrics.sdg_compliance.sdg_12_3.reduction_achieved,
co2_emissions_avoided_kg: metrics.avoided_waste.environmental_impact_avoided.co2_kg,
financial_savings_eur: metrics.financial_impact.potential_monthly_savings,
sdg_compliance_status: metrics.sdg_compliance.sdg_12_3.status_label
},
detailed_metrics: metrics,
certifications: {
sdg_12_3_compliant: metrics.sdg_compliance.certification_ready,
grant_programs_eligible: metrics.grant_readiness.recommended_applications
},
supporting_data: {
baseline_comparison: {
baseline: metrics.sdg_compliance.sdg_12_3.baseline_waste_percentage,
current: metrics.sdg_compliance.sdg_12_3.current_waste_percentage,
improvement: metrics.sdg_compliance.sdg_12_3.reduction_achieved
},
environmental_benefits: metrics.environmental_impact,
financial_benefits: metrics.financial_impact
}
};
}

View File

@@ -0,0 +1,264 @@
// ================================================================
// frontend/src/api/services/tenant.ts
// ================================================================
/**
* Tenant Service - Complete backend alignment
*
* Backend API structure (3-tier architecture):
* - ATOMIC: tenants.py, tenant_members.py
* - OPERATIONS: tenant_operations.py
*
* Last Updated: 2025-10-05
* Status: ✅ Complete - Zero drift with backend
*/
import { apiClient } from '../client';
import {
BakeryRegistration,
BakeryRegistrationWithSubscription,
TenantResponse,
TenantAccessResponse,
TenantUpdate,
TenantMemberResponse,
TenantStatistics,
TenantSearchParams,
TenantNearbyParams,
AddMemberWithUserCreate,
SubscriptionLinkingResponse,
} from '../types/tenant';
export class TenantService {
private readonly baseUrl = '/tenants';
// ===================================================================
// ATOMIC: Tenant CRUD
// Backend: services/tenant/app/api/tenants.py
// ===================================================================
async registerBakery(bakeryData: BakeryRegistration): Promise<TenantResponse> {
return apiClient.post<TenantResponse>(`${this.baseUrl}/register`, bakeryData);
}
async registerBakeryWithSubscription(bakeryData: BakeryRegistrationWithSubscription): Promise<TenantResponse> {
return apiClient.post<TenantResponse>(`${this.baseUrl}/register`, bakeryData);
}
async linkSubscriptionToTenant(
tenantId: string,
subscriptionId: string,
userId: string
): Promise<SubscriptionLinkingResponse> {
return apiClient.post<SubscriptionLinkingResponse>(
`${this.baseUrl}/link-subscription`,
{ tenant_id: tenantId, subscription_id: subscriptionId, user_id: userId }
);
}
async getTenant(tenantId: string): Promise<TenantResponse> {
return apiClient.get<TenantResponse>(`${this.baseUrl}/${tenantId}`);
}
async getTenantBySubdomain(subdomain: string): Promise<TenantResponse> {
return apiClient.get<TenantResponse>(`${this.baseUrl}/subdomain/${subdomain}`);
}
async getUserTenants(userId: string): Promise<TenantResponse[]> {
// Use the /tenants endpoint to get both owned and member tenants
return apiClient.get<TenantResponse[]>(`${this.baseUrl}/user/${userId}/tenants`);
}
async getUserOwnedTenants(userId: string): Promise<TenantResponse[]> {
return apiClient.get<TenantResponse[]>(`${this.baseUrl}/user/${userId}/owned`);
}
async updateTenant(tenantId: string, updateData: TenantUpdate): Promise<TenantResponse> {
return apiClient.put<TenantResponse>(`${this.baseUrl}/${tenantId}`, updateData);
}
async deactivateTenant(tenantId: string): Promise<{ success: boolean; message: string }> {
return apiClient.post<{ success: boolean; message: string }>(`${this.baseUrl}/${tenantId}/deactivate`);
}
async activateTenant(tenantId: string): Promise<{ success: boolean; message: string }> {
return apiClient.post<{ success: boolean; message: string }>(`${this.baseUrl}/${tenantId}/activate`);
}
// ===================================================================
// OPERATIONS: Access Control
// Backend: services/tenant/app/api/tenant_operations.py
// ===================================================================
async verifyTenantAccess(tenantId: string, userId: string): Promise<TenantAccessResponse> {
return apiClient.get<TenantAccessResponse>(`${this.baseUrl}/${tenantId}/access/${userId}`);
}
async getCurrentUserTenantAccess(tenantId: string): Promise<TenantAccessResponse> {
// This will use the current user from the auth token
// The backend endpoint handles extracting user_id from the token
return apiClient.get<TenantAccessResponse>(`${this.baseUrl}/${tenantId}/my-access`);
}
// ===================================================================
// OPERATIONS: Enterprise Hierarchy
// Backend: services/tenant/app/api/tenant_hierarchy.py
// ===================================================================
async getChildTenants(parentTenantId: string): Promise<TenantResponse[]> {
return apiClient.get<TenantResponse[]>(`${this.baseUrl}/${parentTenantId}/children`);
}
async bulkCreateChildTenants(parentTenantId: string, request: {
child_tenants: Array<{
name: string;
city: string;
zone?: string;
address: string;
postal_code: string;
location_code: string;
latitude?: number;
longitude?: number;
phone?: string;
email?: string;
business_type?: string;
business_model?: string;
timezone?: string;
metadata?: Record<string, any>;
}>;
auto_configure_distribution?: boolean;
}): Promise<{
parent_tenant_id: string;
created_count: number;
failed_count: number;
created_tenants: TenantResponse[];
failed_tenants: Array<{ name: string; location_code: string; error: string }>;
distribution_configured: boolean;
}> {
return apiClient.post(`${this.baseUrl}/${parentTenantId}/bulk-children`, request);
}
// ===================================================================
// OPERATIONS: Search & Discovery
// Backend: services/tenant/app/api/tenant_operations.py
// ===================================================================
async searchTenants(params: TenantSearchParams): Promise<TenantResponse[]> {
const queryParams = new URLSearchParams();
if (params.search_term) queryParams.append('search_term', params.search_term);
if (params.business_type) queryParams.append('business_type', params.business_type);
if (params.city) queryParams.append('city', params.city);
if (params.skip !== undefined) queryParams.append('skip', params.skip.toString());
if (params.limit !== undefined) queryParams.append('limit', params.limit.toString());
return apiClient.get<TenantResponse[]>(`${this.baseUrl}/search?${queryParams.toString()}`);
}
async getNearbyTenants(params: TenantNearbyParams): Promise<TenantResponse[]> {
const queryParams = new URLSearchParams();
queryParams.append('latitude', params.latitude.toString());
queryParams.append('longitude', params.longitude.toString());
if (params.radius_km !== undefined) queryParams.append('radius_km', params.radius_km.toString());
if (params.limit !== undefined) queryParams.append('limit', params.limit.toString());
return apiClient.get<TenantResponse[]>(`${this.baseUrl}/nearby?${queryParams.toString()}`);
}
// ===================================================================
// OPERATIONS: Model Status Management
// Backend: services/tenant/app/api/tenant_operations.py
// ===================================================================
async updateModelStatus(
tenantId: string,
modelTrained: boolean,
lastTrainingDate?: string
): Promise<TenantResponse> {
const queryParams = new URLSearchParams();
queryParams.append('model_trained', modelTrained.toString());
if (lastTrainingDate) queryParams.append('last_training_date', lastTrainingDate);
return apiClient.put<TenantResponse>(`${this.baseUrl}/${tenantId}/model-status?${queryParams.toString()}`);
}
// ===================================================================
// ATOMIC: Team Member Management
// Backend: services/tenant/app/api/tenant_members.py
// ===================================================================
async addTeamMember(
tenantId: string,
userId: string,
role: string
): Promise<TenantMemberResponse> {
return apiClient.post<TenantMemberResponse>(`${this.baseUrl}/${tenantId}/members`, {
user_id: userId,
role: role,
});
}
async addTeamMemberWithUserCreation(
tenantId: string,
memberData: AddMemberWithUserCreate
): Promise<TenantMemberResponse> {
return apiClient.post<TenantMemberResponse>(
`${this.baseUrl}/${tenantId}/members/with-user`,
memberData
);
}
async getTeamMembers(tenantId: string, activeOnly: boolean = true): Promise<TenantMemberResponse[]> {
const queryParams = new URLSearchParams();
queryParams.append('active_only', activeOnly.toString());
return apiClient.get<TenantMemberResponse[]>(`${this.baseUrl}/${tenantId}/members?${queryParams.toString()}`);
}
async updateMemberRole(
tenantId: string,
memberUserId: string,
newRole: string
): Promise<TenantMemberResponse> {
return apiClient.put<TenantMemberResponse>(
`${this.baseUrl}/${tenantId}/members/${memberUserId}/role`,
{ new_role: newRole }
);
}
async removeTeamMember(tenantId: string, memberUserId: string): Promise<{ success: boolean; message: string }> {
return apiClient.delete<{ success: boolean; message: string }>(`${this.baseUrl}/${tenantId}/members/${memberUserId}`);
}
/**
* Transfer tenant ownership to another admin
* Backend: services/tenant/app/api/tenant_members.py - transfer_ownership endpoint
*
* @param tenantId - The tenant ID
* @param newOwnerId - The user ID of the new owner (must be an existing admin)
* @returns Updated tenant with new owner
*/
async transferOwnership(tenantId: string, newOwnerId: string): Promise<TenantResponse> {
return apiClient.post<TenantResponse>(
`${this.baseUrl}/${tenantId}/transfer-ownership`,
{ new_owner_id: newOwnerId }
);
}
// ===================================================================
// OPERATIONS: Statistics & Admin
// Backend: services/tenant/app/api/tenant_operations.py
// ===================================================================
async getTenantStatistics(): Promise<TenantStatistics> {
return apiClient.get<TenantStatistics>(`${this.baseUrl}/statistics`);
}
// ===================================================================
// Frontend Context Management
// ===================================================================
setCurrentTenant(tenant: TenantResponse): void {
// Set tenant context in API client
if (tenant && tenant.id) {
apiClient.setTenantId(tenant.id);
}
}
clearCurrentTenant(): void {
// Clear tenant context from API client
apiClient.setTenantId(null);
}
}
export const tenantService = new TenantService();

View File

@@ -0,0 +1,198 @@
// ================================================================
// frontend/src/api/services/training.ts
// ================================================================
/**
* Training Service - Complete backend alignment
*
* Backend API structure (3-tier architecture):
* - ATOMIC: training_jobs.py, models.py
* - OPERATIONS: training_operations.py
*
* Last Updated: 2025-10-05
* Status: ✅ Complete - Zero drift with backend
*/
import { apiClient } from '../client/apiClient';
import type {
TrainingJobRequest,
TrainingJobResponse,
TrainingJobStatus,
SingleProductTrainingRequest,
ActiveModelResponse,
ModelMetricsResponse,
TrainedModelResponse,
TenantStatistics,
ModelPerformanceResponse,
ModelsQueryParams,
PaginatedResponse,
} from '../types/training';
class TrainingService {
private readonly baseUrl = '/tenants';
// ===================================================================
// OPERATIONS: Training Job Creation
// Backend: services/training/app/api/training_operations.py
// ===================================================================
/**
* Create a new training job
* POST /tenants/{tenant_id}/training/jobs
*/
async createTrainingJob(
tenantId: string,
request: TrainingJobRequest
): Promise<TrainingJobResponse> {
return apiClient.post<TrainingJobResponse>(
`${this.baseUrl}/${tenantId}/training/jobs`,
request
);
}
/**
* Train a single product
* POST /tenants/{tenant_id}/training/products/{inventory_product_id}
*/
async trainSingleProduct(
tenantId: string,
inventoryProductId: string,
request: SingleProductTrainingRequest
): Promise<TrainingJobResponse> {
return apiClient.post<TrainingJobResponse>(
`${this.baseUrl}/${tenantId}/training/products/${inventoryProductId}`,
request
);
}
// ===================================================================
// ATOMIC: Training Job Status
// Backend: services/training/app/api/training_jobs.py
// ===================================================================
/**
* Get training job status
* GET /tenants/{tenant_id}/training/jobs/{job_id}/status
*/
async getTrainingJobStatus(
tenantId: string,
jobId: string
): Promise<TrainingJobStatus> {
return apiClient.get<TrainingJobStatus>(
`${this.baseUrl}/${tenantId}/training/jobs/${jobId}/status`
);
}
/**
* Get training statistics
* GET /tenants/{tenant_id}/training/statistics
*/
async getTenantStatistics(tenantId: string): Promise<TenantStatistics> {
return apiClient.get<TenantStatistics>(
`${this.baseUrl}/${tenantId}/training/statistics`
);
}
// ===================================================================
// ATOMIC: Model Management
// Backend: services/training/app/api/models.py
// ===================================================================
/**
* Get active model for a product
* GET /tenants/{tenant_id}/training/models/{inventory_product_id}/active
*/
async getActiveModel(
tenantId: string,
inventoryProductId: string
): Promise<ActiveModelResponse> {
return apiClient.get<ActiveModelResponse>(
`${this.baseUrl}/${tenantId}/training/models/${inventoryProductId}/active`
);
}
/**
* Get model metrics
* GET /tenants/{tenant_id}/training/models/{model_id}/metrics
*/
async getModelMetrics(
tenantId: string,
modelId: string
): Promise<ModelMetricsResponse> {
return apiClient.get<ModelMetricsResponse>(
`${this.baseUrl}/${tenantId}/training/models/${modelId}/metrics`
);
}
/**
* List models with optional filters
* GET /tenants/{tenant_id}/training/models
*/
async getModels(
tenantId: string,
queryParams?: ModelsQueryParams
): Promise<PaginatedResponse<TrainedModelResponse>> {
const params = new URLSearchParams();
if (queryParams?.status) params.append('status', queryParams.status);
if (queryParams?.model_type) params.append('model_type', queryParams.model_type);
if (queryParams?.limit) params.append('limit', queryParams.limit.toString());
if (queryParams?.offset) params.append('offset', queryParams.offset.toString());
const queryString = params.toString() ? `?${params.toString()}` : '';
return apiClient.get<PaginatedResponse<TrainedModelResponse>>(
`${this.baseUrl}/${tenantId}/training/models${queryString}`
);
}
/**
* Get model performance metrics
* Note: This endpoint might be deprecated - check backend for actual implementation
*/
async getModelPerformance(
tenantId: string,
modelId: string
): Promise<ModelPerformanceResponse> {
return apiClient.get<ModelPerformanceResponse>(
`${this.baseUrl}/${tenantId}/training/models/${modelId}/performance`
);
}
/**
* Delete all tenant models (Admin only)
* DELETE /models/tenant/{tenant_id}
*/
async deleteAllTenantModels(tenantId: string): Promise<{ message: string }> {
return apiClient.delete<{ message: string }>(`/models/tenant/${tenantId}`);
}
// ===================================================================
// WebSocket Support
// ===================================================================
/**
* Get WebSocket URL for real-time training updates
*/
getTrainingWebSocketUrl(tenantId: string, jobId: string): string {
const baseWsUrl = apiClient.getAxiosInstance().defaults.baseURL
?.replace(/^http(s?):/, 'ws$1:'); // http: → ws:, https: → wss:
return `${baseWsUrl}/tenants/${tenantId}/training/jobs/${jobId}/live`;
}
/**
* Helper method to construct WebSocket connection
*/
createWebSocketConnection(
tenantId: string,
jobId: string,
token?: string
): WebSocket {
const wsUrl = this.getTrainingWebSocketUrl(tenantId, jobId);
const urlWithToken = token ? `${wsUrl}?token=${token}` : wsUrl;
return new WebSocket(urlWithToken);
}
}
// Create and export singleton instance
export const trainingService = new TrainingService();
export default trainingService;

View File

@@ -0,0 +1,49 @@
/**
* User Service - Mirror backend user endpoints
*/
import { apiClient } from '../client';
import { UserResponse, UserUpdate } from '../types/auth';
import { AdminDeleteRequest, AdminDeleteResponse } from '../types/user';
export class UserService {
private readonly baseUrl = '/users';
async getCurrentUser(): Promise<UserResponse> {
// Get current user ID from auth store
const authStore = useAuthStore.getState();
const userId = authStore.user?.id;
if (!userId) {
throw new Error('No authenticated user found');
}
return apiClient.get<UserResponse>(`${this.baseUrl}/${userId}`);
}
async updateUser(userId: string, updateData: UserUpdate): Promise<UserResponse> {
return apiClient.put<UserResponse>(`${this.baseUrl}/${userId}`, updateData);
}
async deleteUser(userId: string): Promise<{ message: string }> {
return apiClient.delete<{ message: string }>(`${this.baseUrl}/${userId}`);
}
// Admin operations
async adminDeleteUser(deleteRequest: AdminDeleteRequest): Promise<AdminDeleteResponse> {
return apiClient.post<AdminDeleteResponse>(`${this.baseUrl}/admin/delete`, deleteRequest);
}
async getAllUsers(): Promise<UserResponse[]> {
return apiClient.get<UserResponse[]>(`${this.baseUrl}/admin/all`);
}
async getUserById(userId: string): Promise<UserResponse> {
return apiClient.get<UserResponse>(`${this.baseUrl}/admin/${userId}`);
}
async getUserActivity(userId: string): Promise<any> {
return apiClient.get<any>(`/auth/users/${userId}/activity`);
}
}
export const userService = new UserService();

View File

@@ -0,0 +1,84 @@
// ================================================================
// frontend/src/api/types/auditLogs.ts
// ================================================================
/**
* Audit Log Types - TypeScript interfaces for audit log data
*
* Aligned with backend schema:
* - shared/models/audit_log_schemas.py
*
* Last Updated: 2025-11-02
* Status: ✅ Complete - Aligned with backend
*/
export interface AuditLogResponse {
id: string;
tenant_id: string;
user_id: string | null;
service_name: string;
action: string;
resource_type: string;
resource_id: string | null;
severity: 'low' | 'medium' | 'high' | 'critical';
description: string;
changes: Record<string, any> | null;
audit_metadata: Record<string, any> | null;
endpoint: string | null;
method: string | null;
ip_address: string | null;
user_agent: string | null;
created_at: string;
}
export interface AuditLogFilters {
start_date?: string;
end_date?: string;
user_id?: string;
action?: string;
resource_type?: string;
severity?: 'low' | 'medium' | 'high' | 'critical';
search?: string;
limit?: number;
offset?: number;
}
export interface AuditLogListResponse {
items: AuditLogResponse[];
total: number;
limit: number;
offset: number;
has_more: boolean;
}
export interface AuditLogStatsResponse {
total_events: number;
events_by_action: Record<string, number>;
events_by_severity: Record<string, number>;
events_by_resource_type: Record<string, number>;
date_range: {
min: string | null;
max: string | null;
};
}
// Aggregated audit log (combines logs from all services)
export interface AggregatedAuditLog extends AuditLogResponse {
// All fields from AuditLogResponse, service_name distinguishes the source
}
// Service list for audit log aggregation
export const AUDIT_LOG_SERVICES = [
'sales',
'inventory',
'orders',
'production',
'recipes',
'suppliers',
'pos',
'training',
'notification',
'external',
'forecasting',
] as const;
export type AuditLogServiceName = typeof AUDIT_LOG_SERVICES[number];

View File

@@ -0,0 +1,368 @@
// ================================================================
// frontend/src/api/types/auth.ts
// ================================================================
/**
* Authentication Type Definitions
*
* Aligned with backend schemas:
* - services/auth/app/schemas/auth.py
* - services/auth/app/schemas/users.py
*
* Last Updated: 2025-01-14
* Status: Complete - Atomic registration flow with 3DS support
*/
// ================================================================
// REQUEST TYPES
// ================================================================
/**
* User registration request
* Backend: services/auth/app/schemas/auth.py:15-29 (UserRegistration)
* Updated for new atomic registration flow with 3DS support
*/
export interface UserRegistration {
email: string; // EmailStr - validated email format
password: string; // min_length=8, max_length=128
full_name: string; // min_length=1, max_length=255
tenant_name?: string | null; // max_length=255
role?: string | null; // Default: "admin", pattern: ^(user|admin|manager|super_admin)$
subscription_plan?: string | null; // Default: "starter", options: starter, professional, enterprise
billing_cycle?: 'monthly' | 'yearly' | null; // Default: "monthly" - Billing cycle preference
payment_method_id?: string | null; // Stripe payment method ID
coupon_code?: string | null; // Promotional coupon code for discounts/trial extensions
// Payment setup data (passed to complete-registration after 3DS)
customer_id?: string | null; // Stripe customer ID from payment setup
trial_period_days?: number | null; // Trial period from coupon
// GDPR Consent fields
terms_accepted?: boolean; // Default: true - Accept terms of service
privacy_accepted?: boolean; // Default: true - Accept privacy policy
marketing_consent?: boolean; // Default: false - Consent to marketing communications
analytics_consent?: boolean; // Default: false - Consent to analytics cookies
// Billing address fields for subscription creation
address?: string | null; // Billing address
postal_code?: string | null; // Billing postal code
city?: string | null; // Billing city
country?: string | null; // Billing country
}
/**
* Registration verification data for 3DS completion
* Used in the second step of the atomic registration flow
*/
export interface RegistrationVerification {
setup_intent_id: string; // SetupIntent ID from first step
user_data: UserRegistration; // Original user registration data
state_id?: string | null; // Optional registration state ID for tracking
}
/**
* Registration start response (first step)
* Response from /start-registration endpoint
* Backend: services/auth/app/api/auth_operations.py:start_registration()
*/
export interface RegistrationStartResponse {
requires_action: boolean; // Whether 3DS/SetupIntent authentication is required
action_type?: string | null; // Type of action required (e.g., 'setup_intent_confirmation')
client_secret?: string | null; // Client secret for SetupIntent authentication
setup_intent_id?: string | null; // SetupIntent ID for 3DS authentication
customer_id?: string | null; // Stripe customer ID
payment_customer_id?: string | null; // Payment customer ID
plan_id?: string | null; // Plan ID
payment_method_id?: string | null; // Payment method ID
billing_cycle?: string | null; // Billing cycle
trial_period_days?: number | null; // Trial period from coupon (e.g., 90 for PILOT2025)
email?: string | null; // User email
state_id?: string | null; // Registration state ID for tracking
message?: string | null; // Message explaining what needs to be done
user?: UserData | null; // User data (only if no 3DS required)
subscription_id?: string | null; // Subscription ID (only if no 3DS required)
status?: string | null; // Status (only if no 3DS required)
}
/**
* Registration completion response (second step)
* Response from /complete-registration endpoint
* Backend: services/auth/app/api/auth_operations.py:complete_registration()
*/
export interface RegistrationCompletionResponse {
success: boolean; // Whether registration was successful
user?: UserData | null; // Created user data
subscription_id?: string | null; // Created subscription ID
payment_customer_id?: string | null; // Payment customer ID
status?: string | null; // Subscription status
message?: string | null; // Success/error message
access_token?: string | null; // JWT access token
refresh_token?: string | null; // JWT refresh token
}
/**
* User login request
* Backend: services/auth/app/schemas/auth.py:26-29 (UserLogin)
*/
export interface UserLogin {
email: string; // EmailStr - validated email format
password: string;
}
/**
* Refresh token request
* Backend: services/auth/app/schemas/auth.py:31-33 (RefreshTokenRequest)
*/
export interface RefreshTokenRequest {
refresh_token: string;
}
/**
* Password change request
* Backend: services/auth/app/schemas/auth.py:35-38 (PasswordChange)
*/
export interface PasswordChange {
current_password: string;
new_password: string; // min_length=8, max_length=128
}
/**
* Password reset request (initiate reset)
* Backend: services/auth/app/schemas/auth.py:40-42 (PasswordReset)
*/
export interface PasswordReset {
email: string; // EmailStr - validated email format
}
/**
* Password reset confirmation (complete reset)
* Backend: services/auth/app/schemas/auth.py:44-47 (PasswordResetConfirm)
*/
export interface PasswordResetConfirm {
token: string;
new_password: string; // min_length=8, max_length=128
}
/**
* Email verification request
* Backend: services/auth/app/schemas/auth.py:173-175 (EmailVerificationRequest)
*/
export interface EmailVerificationRequest {
email: string; // EmailStr - validated email format
}
/**
* Email verification confirmation
* Backend: services/auth/app/schemas/auth.py:177-179 (EmailVerificationConfirm)
*/
export interface EmailVerificationConfirm {
token: string;
}
/**
* Profile update request
* Backend: services/auth/app/schemas/auth.py:181-184 (ProfileUpdate)
*/
export interface ProfileUpdate {
full_name?: string | null; // min_length=1, max_length=255
email?: string | null; // EmailStr - validated email format
}
/**
* User update schema
* Backend: services/auth/app/schemas/users.py:14-26 (UserUpdate)
*/
export interface UserUpdate {
full_name?: string | null; // min_length=2, max_length=100
phone?: string | null; // Spanish phone validation applied on backend
language?: string | null; // pattern: ^(es|en)$
timezone?: string | null;
}
// ================================================================
// RESPONSE TYPES
// ================================================================
/**
* User data embedded in token responses
* Backend: services/auth/app/schemas/auth.py:53-62 (UserData)
*/
export interface UserData {
id: string;
email: string;
full_name: string;
is_active: boolean;
is_verified: boolean;
created_at: string; // ISO format datetime string
tenant_id?: string | null;
role?: string | null; // Default: "admin"
}
/**
* Unified token response for both registration and login
* Follows industry standards (Firebase, AWS Cognito, etc.)
* Backend: services/auth/app/schemas/auth.py:64-92 (TokenResponse)
*/
export interface TokenResponse {
access_token: string;
refresh_token?: string | null;
token_type: string; // Default: "bearer"
expires_in: number; // Default: 3600 seconds
user?: UserData | null;
}
/**
* User response for user management endpoints
* Backend: services/auth/app/schemas/auth.py:94-110 (UserResponse)
*/
export interface UserResponse {
id: string;
email: string;
full_name: string;
is_active: boolean;
is_verified: boolean;
created_at: string; // ISO datetime string
last_login?: string | null; // ISO datetime string
phone?: string | null;
language?: string | null;
timezone?: string | null;
tenant_id?: string | null;
role?: string | null; // Default: "admin"
payment_customer_id?: string | null; // Payment provider customer ID (Stripe, etc.)
default_payment_method_id?: string | null; // Default payment method ID
}
/**
* User profile schema
* Backend: services/auth/app/schemas/users.py:28-42 (UserProfile)
*/
export interface UserProfile {
id: string;
email: string;
full_name: string;
phone?: string | null;
language: string;
timezone: string;
is_active: boolean;
is_verified: boolean;
created_at: string; // ISO datetime string
last_login?: string | null; // ISO datetime string
}
/**
* Token verification response
* Backend: services/auth/app/schemas/auth.py:123-129 (TokenVerification)
*/
export interface TokenVerification {
valid: boolean;
user_id?: string | null;
email?: string | null;
exp?: number | null; // Expiration timestamp
message?: string | null;
}
/**
* Token verification response (alias for hooks)
*/
export type TokenVerificationResponse = TokenVerification;
/**
* Password reset response
* Backend: services/auth/app/schemas/auth.py:131-134 (PasswordResetResponse)
*/
export interface PasswordResetResponse {
message: string;
reset_token?: string | null;
}
/**
* Logout response
* Backend: services/auth/app/schemas/auth.py:136-139 (LogoutResponse)
*/
export interface LogoutResponse {
message: string;
success: boolean; // Default: true
}
/**
* Auth health response
*/
export interface AuthHealthResponse {
status: string;
service: string;
version?: string;
features?: string[];
}
// ================================================================
// ERROR TYPES
// ================================================================
/**
* Error detail for API responses
* Backend: services/auth/app/schemas/auth.py:145-149 (ErrorDetail)
*/
export interface ErrorDetail {
message: string;
code?: string | null;
field?: string | null;
}
/**
* Standardized error response
* Backend: services/auth/app/schemas/auth.py:151-167 (ErrorResponse)
*/
export interface ErrorResponse {
success: boolean; // Default: false
error: ErrorDetail;
timestamp: string; // ISO datetime string
}
// ================================================================
// INTERNAL TYPES (for service communication)
// ================================================================
/**
* User context for internal service communication
* Backend: services/auth/app/schemas/auth.py:190-196 (UserContext)
*/
export interface UserContext {
user_id: string;
email: string;
tenant_id?: string | null;
roles: string[]; // Default: ["admin"]
is_verified: boolean; // Default: false
}
/**
* JWT token claims structure
* Backend: services/auth/app/schemas/auth.py:198-208 (TokenClaims)
*/
export interface TokenClaims {
sub: string; // subject (user_id)
email: string;
full_name: string;
user_id: string;
is_verified: boolean;
tenant_id?: string | null;
iat: number; // issued at timestamp
exp: number; // expires at timestamp
iss: string; // issuer - Default: "bakery-auth"
}
// ================================================================
// 3DS Authentication Types
// ================================================================
/**
* Exception thrown when 3DS authentication is required
* This is used in the SetupIntent-first registration flow
*/
export class ThreeDSAuthenticationRequired extends Error {
constructor(
public setup_intent_id: string,
public client_secret: string,
public action_type: string,
message: string = "3DS authentication required",
public extra_data: Record<string, unknown> = {}
) {
super(message);
this.name = "ThreeDSAuthenticationRequired";
}
}

View File

@@ -0,0 +1,35 @@
/**
* Product Classification API Types - Mirror backend schemas
*/
export interface ProductClassificationRequest {
product_name: string;
sales_volume?: number;
sales_data?: Record<string, any>;
}
export interface BatchClassificationRequest {
products: ProductClassificationRequest[];
}
export interface ProductSuggestionResponse {
suggestion_id: string;
original_name: string;
suggested_name: string;
product_type: string;
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;
sales_data?: {
total_quantity: number;
average_daily_sales: number;
peak_day: string;
frequency: number;
};
}

View File

@@ -0,0 +1,111 @@
/**
* Dashboard API Types - Mirror backend schemas
*/
export interface InventoryDashboardSummary {
tenant_id: string;
total_ingredients: number;
total_stock_value: number;
low_stock_count: number;
out_of_stock_count: number;
overstock_count: number;
expiring_soon_count: number;
expired_count: number;
recent_movements: StockMovementSummary[];
top_categories: CategorySummary[];
alerts_summary: AlertSummary;
last_updated: string;
}
export interface StockMovementSummary {
id: string;
ingredient_name: string;
movement_type: 'in' | 'out' | 'adjustment' | 'transfer' | 'waste';
quantity: number;
created_at: string;
}
export interface CategorySummary {
category: string;
ingredient_count: number;
total_value: number;
low_stock_count: number;
}
export interface AlertSummary {
total_alerts: number;
critical_alerts: number;
warning_alerts: number;
info_alerts: number;
}
export interface StockStatusSummary {
in_stock: number;
low_stock: number;
out_of_stock: number;
overstock: number;
}
export interface InventoryAnalytics {
tenant_id: string;
period_start: string;
period_end: string;
stock_turnover_rate: number;
average_days_to_consume: number;
waste_percentage: number;
cost_of_goods_sold: number;
inventory_value_trend: Array<{
date: string;
value: number;
}>;
top_consuming_ingredients: Array<{
ingredient_name: string;
quantity_consumed: number;
value_consumed: number;
}>;
seasonal_patterns: Record<string, any>;
}
export interface BusinessModelInsights {
tenant_id: string;
business_type: string;
primary_categories: string[];
seasonality_score: number;
optimization_opportunities: Array<{
type: 'stock_level' | 'supplier' | 'storage' | 'ordering';
description: string;
potential_savings: number;
priority: 'high' | 'medium' | 'low';
}>;
benchmarking: {
inventory_turnover_vs_industry: number;
waste_percentage_vs_industry: number;
storage_efficiency_score: number;
};
}
export interface RecentActivity {
id: string;
type: 'stock_in' | 'stock_out' | 'ingredient_created' | 'alert_created';
description: string;
timestamp: string;
user_name?: string;
}
export interface DashboardFilter {
date_range?: {
start: string;
end: string;
};
categories?: string[];
include_expired?: boolean;
include_unavailable?: boolean;
}
export interface AlertsFilter {
severity?: 'critical' | 'warning' | 'info';
type?: 'expiry' | 'low_stock' | 'out_of_stock' | 'food_safety';
resolved?: boolean;
limit?: number;
offset?: number;
}

View File

@@ -0,0 +1,69 @@
/**
* Data Import API Types - Mirror backend schemas
*/
export interface ImportValidationRequest {
tenant_id: string;
data?: string;
data_format?: 'json' | 'csv';
}
export interface ImportValidationResponse {
is_valid: boolean;
total_records: number;
valid_records: number;
invalid_records: number;
errors: Array<Record<string, any>>;
warnings: Array<Record<string, any>>;
summary: Record<string, any>;
unique_products: number;
product_list: string[];
message: string;
details: {
total_records: number;
format: string;
};
sample_records?: any[]; // Keep for backward compatibility
}
export interface ImportProcessRequest {
tenant_id: string;
data?: string;
data_format?: 'json' | 'csv';
options?: {
skip_validation?: boolean;
chunk_size?: number;
};
}
export interface ImportProcessResponse {
success: boolean;
message: string;
records_processed: number;
records_failed: number;
import_id?: string;
errors?: string[];
import_summary?: {
total_records: number;
successful_imports: number;
failed_imports: number;
processing_time_seconds: number;
timestamp: string;
};
details?: {
tenant_id: string;
file_name?: string;
processing_status: string;
};
}
export interface ImportStatusResponse {
import_id: string;
status: 'pending' | 'processing' | 'completed' | 'failed';
progress_percentage: number;
records_processed: number;
records_failed: number;
started_at: string;
completed_at?: string;
errors?: string[];
}

View File

@@ -0,0 +1,146 @@
// ================================================================
// frontend/src/api/types/demo.ts
// ================================================================
/**
* Demo Session Type Definitions
*
* Aligned with backend schema:
* - services/demo_session/app/api/schemas.py
*
* Last Updated: 2025-10-05
* Status: ✅ Complete - Zero drift with backend
*/
// ================================================================
// REQUEST TYPES
// ================================================================
/**
* Create demo session request
* Backend: services/demo_session/app/api/schemas.py:10-15 (DemoSessionCreate)
*/
export interface DemoSessionCreate {
demo_account_type: string; // professional or enterprise
user_id?: string | null; // Optional authenticated user ID
ip_address?: string | null;
user_agent?: string | null;
}
/**
* Extend session request
* Backend: services/demo_session/app/api/schemas.py:33-35 (DemoSessionExtend)
*/
export interface DemoSessionExtend {
session_id: string;
}
/**
* Destroy session request
* Backend: services/demo_session/app/api/schemas.py:38-40 (DemoSessionDestroy)
*/
export interface DemoSessionDestroy {
session_id: string;
}
/**
* Request to clone tenant data
* Backend: services/demo_session/app/api/schemas.py:64-68 (CloneDataRequest)
*/
export interface CloneDataRequest {
base_tenant_id: string;
virtual_tenant_id: string;
session_id: string;
}
// ================================================================
// RESPONSE TYPES
// ================================================================
/**
* Demo session response
* Backend: services/demo_session/app/api/schemas.py:18-30 (DemoSessionResponse)
*/
/**
* Demo user data returned in session response
* Matches the structure of a real login user response
*/
export interface DemoUser {
id: string;
email: string;
full_name: string;
role: string;
is_active: boolean;
is_verified: boolean;
tenant_id: string;
created_at: string;
}
/**
* Demo tenant data returned in session response
* Matches the structure of a real tenant response
*/
export interface DemoTenant {
id: string;
name: string;
subdomain: string;
subscription_tier: string;
tenant_type: string;
business_type: string;
business_model: string;
description: string;
is_active: boolean;
}
export interface DemoSessionResponse {
session_id: string;
virtual_tenant_id: string;
demo_account_type: string;
status: string;
created_at: string; // ISO datetime
expires_at: string; // ISO datetime
demo_config: Record<string, any>;
session_token: string;
subscription_tier: string;
is_enterprise: boolean;
// Complete user and tenant data (like a real login response)
user: DemoUser;
tenant: DemoTenant;
}
/**
* Demo session statistics
* Backend: services/demo_session/app/api/schemas.py:43-50 (DemoSessionStats)
*/
export interface DemoSessionStats {
total_sessions: number;
active_sessions: number;
expired_sessions: number;
destroyed_sessions: number;
avg_duration_minutes: number;
total_requests: number;
}
/**
* Public demo account information
* Backend: services/demo_session/app/api/schemas.py:53-61 (DemoAccountInfo)
*/
export interface DemoAccountInfo {
account_type: string;
name: string;
email: string;
password: string;
description: string;
features: string[];
business_model: string;
}
/**
* Response from data cloning
* Backend: services/demo_session/app/api/schemas.py:71-76 (CloneDataResponse)
*/
export interface CloneDataResponse {
session_id: string;
services_cloned: string[];
total_records: number;
redis_keys: number;
}

View File

@@ -0,0 +1,173 @@
// Types for equipment management
export interface EquipmentAlert {
id: string;
type: 'warning' | 'critical' | 'info';
message: string;
timestamp: string;
acknowledged: boolean;
}
export interface MaintenanceHistory {
id: string;
date: string;
type: 'preventive' | 'corrective' | 'emergency';
description: string;
technician: string;
cost: number;
downtime: number; // hours
partsUsed: string[];
}
export interface EquipmentSpecifications {
power: number; // kW
capacity: number;
dimensions: {
width: number;
height: number;
depth: number;
};
weight: number;
}
export interface Equipment {
id: string;
tenant_id?: string;
name: string;
type: 'oven' | 'mixer' | 'proofer' | 'freezer' | 'packaging' | 'other';
model: string;
serialNumber: string;
location: string;
status: 'operational' | 'maintenance' | 'down' | 'warning';
installDate: string;
lastMaintenance: string;
nextMaintenance: string;
maintenanceInterval: number; // days
temperature?: number;
targetTemperature?: number;
efficiency: number;
uptime: number;
energyUsage: number;
utilizationToday: number;
alerts: EquipmentAlert[];
maintenanceHistory: MaintenanceHistory[];
specifications: EquipmentSpecifications;
is_active?: boolean;
support_contact?: {
email?: string;
phone?: string;
company?: string;
contract_number?: string;
response_time_sla?: number;
};
created_at?: string;
updated_at?: string;
}
// API Request/Response types
export type EquipmentType = 'oven' | 'mixer' | 'proofer' | 'freezer' | 'packaging' | 'other';
export type EquipmentStatus = 'operational' | 'maintenance' | 'down' | 'warning';
export interface EquipmentCreate {
name: string;
type: EquipmentType;
model?: string;
serial_number?: string;
location?: string;
status?: EquipmentStatus;
install_date?: string;
last_maintenance_date?: string;
next_maintenance_date?: string;
maintenance_interval_days?: number;
efficiency_percentage?: number;
uptime_percentage?: number;
energy_usage_kwh?: number;
power_kw?: number;
capacity?: number;
weight_kg?: number;
current_temperature?: number;
target_temperature?: number;
notes?: string;
support_contact?: {
email?: string;
phone?: string;
company?: string;
contract_number?: string;
response_time_sla?: number;
};
}
export interface EquipmentUpdate {
name?: string;
type?: EquipmentType;
model?: string;
serial_number?: string;
location?: string;
status?: EquipmentStatus;
install_date?: string;
last_maintenance_date?: string;
next_maintenance_date?: string;
maintenance_interval_days?: number;
efficiency_percentage?: number;
uptime_percentage?: number;
energy_usage_kwh?: number;
power_kw?: number;
capacity?: number;
weight_kg?: number;
current_temperature?: number;
target_temperature?: number;
is_active?: boolean;
notes?: string;
}
export interface EquipmentResponse {
id: string;
tenant_id: string;
name: string;
type: EquipmentType;
model: string | null;
serial_number: string | null;
location: string | null;
status: EquipmentStatus;
install_date: string | null;
last_maintenance_date: string | null;
next_maintenance_date: string | null;
maintenance_interval_days: number | null;
efficiency_percentage: number | null;
uptime_percentage: number | null;
energy_usage_kwh: number | null;
power_kw: number | null;
capacity: number | null;
weight_kg: number | null;
current_temperature: number | null;
target_temperature: number | null;
is_active: boolean;
notes: string | null;
support_contact: {
email?: string;
phone?: string;
company?: string;
contract_number?: string;
response_time_sla?: number;
} | null;
created_at: string;
updated_at: string;
}
export interface EquipmentListResponse {
equipment: EquipmentResponse[];
total_count: number;
page: number;
page_size: number;
}
export interface EquipmentDeletionSummary {
can_delete: boolean;
warnings: string[];
production_batches_count: number;
maintenance_records_count: number;
temperature_logs_count: number;
equipment_name?: string;
equipment_type?: string;
equipment_location?: string;
}

View File

@@ -0,0 +1,448 @@
/**
* Unified Event Type System - Single Source of Truth
*
* Complete rewrite matching backend response structure exactly.
* NO backward compatibility, NO legacy fields.
*
* Backend files this mirrors:
* - /services/alert_processor/app/models/events_clean.py
* - /services/alert_processor/app/models/response_models_clean.py
*/
// ============================================================
// ENUMS - Matching Backend Exactly
// ============================================================
export enum EventClass {
ALERT = 'alert',
NOTIFICATION = 'notification',
RECOMMENDATION = 'recommendation',
}
export enum AlertTypeClass {
ACTION_NEEDED = 'action_needed',
PREVENTED_ISSUE = 'prevented_issue',
TREND_WARNING = 'trend_warning',
ESCALATION = 'escalation',
INFORMATION = 'information',
}
export enum PriorityLevel {
CRITICAL = 'critical', // 90-100
IMPORTANT = 'important', // 70-89
STANDARD = 'standard', // 50-69
INFO = 'info', // 0-49
}
export enum AlertStatus {
ACTIVE = 'active',
RESOLVED = 'resolved',
ACKNOWLEDGED = 'acknowledged',
IN_PROGRESS = 'in_progress',
DISMISSED = 'dismissed',
}
export enum SmartActionType {
APPROVE_PO = 'approve_po',
REJECT_PO = 'reject_po',
MODIFY_PO = 'modify_po',
VIEW_PO_DETAILS = 'view_po_details',
CALL_SUPPLIER = 'call_supplier',
NAVIGATE = 'navigate',
ADJUST_PRODUCTION = 'adjust_production',
START_PRODUCTION_BATCH = 'start_production_batch',
NOTIFY_CUSTOMER = 'notify_customer',
CANCEL_AUTO_ACTION = 'cancel_auto_action',
MARK_DELIVERY_RECEIVED = 'mark_delivery_received',
COMPLETE_STOCK_RECEIPT = 'complete_stock_receipt',
OPEN_REASONING = 'open_reasoning',
SNOOZE = 'snooze',
DISMISS = 'dismiss',
MARK_READ = 'mark_read',
}
export enum NotificationType {
STATE_CHANGE = 'state_change',
COMPLETION = 'completion',
ARRIVAL = 'arrival',
DEPARTURE = 'departure',
UPDATE = 'update',
SYSTEM_EVENT = 'system_event',
}
export enum RecommendationType {
OPTIMIZATION = 'optimization',
COST_REDUCTION = 'cost_reduction',
RISK_MITIGATION = 'risk_mitigation',
TREND_INSIGHT = 'trend_insight',
BEST_PRACTICE = 'best_practice',
}
// ============================================================
// CONTEXT INTERFACES - Matching Backend Response Models
// ============================================================
/**
* i18n display context with parameterized content
* Backend field name: "i18n" (NOT "display")
*/
export interface I18nDisplayContext {
title_key: string;
message_key: string;
title_params: Record<string, any>;
message_params: Record<string, any>;
}
export interface BusinessImpactContext {
financial_impact_eur?: number;
waste_prevented_eur?: number;
time_saved_minutes?: number;
production_loss_avoided_eur?: number;
potential_loss_eur?: number;
}
/**
* Urgency context
* Backend field name: "urgency" (NOT "urgency_context")
*/
export interface UrgencyContext {
deadline_utc?: string; // ISO date string
hours_until_consequence?: number;
auto_action_countdown_seconds?: number;
auto_action_cancelled?: boolean;
urgency_reason_key?: string; // i18n key
urgency_reason_params?: Record<string, any>;
priority: string; // "critical", "urgent", "normal", "info"
}
export interface UserAgencyContext {
action_required: boolean;
external_party_required?: boolean;
external_party_name?: string;
external_party_contact?: string;
estimated_resolution_time_minutes?: number;
user_control_level: string; // "full", "partial", "none"
action_urgency: string; // "immediate", "soon", "normal"
}
export interface TrendContext {
metric_name: string;
current_value: number;
baseline_value: number;
change_percentage: number;
direction: 'increasing' | 'decreasing';
significance: 'high' | 'medium' | 'low';
period_days: number;
possible_causes?: string[];
}
/**
* Smart action with parameterized i18n labels
* Backend field name in Alert: "smart_actions" (NOT "actions")
*/
export interface SmartAction {
action_type: string;
label_key: string; // i18n key for button text
label_params?: Record<string, any>;
variant: 'primary' | 'secondary' | 'danger' | 'ghost';
disabled: boolean;
consequence_key?: string; // i18n key for consequence text
consequence_params?: Record<string, any>;
disabled_reason?: string;
disabled_reason_key?: string; // i18n key for disabled reason
disabled_reason_params?: Record<string, any>;
estimated_time_minutes?: number;
metadata: Record<string, any>;
}
export interface AIReasoningContext {
summary_key?: string; // i18n key
summary_params?: Record<string, any>;
details?: Record<string, any>;
}
// ============================================================
// EVENT RESPONSE TYPES - Base and Specific Types
// ============================================================
/**
* Base Event interface with common fields
*/
export interface Event {
// Core Identity
id: string;
tenant_id: string;
event_class: EventClass;
event_domain: string;
event_type: string;
service: string;
// i18n Display Context
// CRITICAL: Backend uses "i18n", NOT "display"
i18n: I18nDisplayContext;
// Classification
priority_level: PriorityLevel;
status: string;
// Timestamps
created_at: string; // ISO date string
updated_at: string; // ISO date string
// Optional context fields
event_metadata?: Record<string, any>;
}
/**
* Alert - Full enrichment, lifecycle tracking
*/
export interface Alert extends Event {
event_class: EventClass.ALERT;
status: AlertStatus | string;
// Alert-specific classification
type_class: AlertTypeClass;
priority_score: number; // 0-100
// Rich Context
// CRITICAL: Backend uses "urgency", NOT "urgency_context"
business_impact?: BusinessImpactContext;
urgency?: UrgencyContext;
user_agency?: UserAgencyContext;
trend_context?: TrendContext;
orchestrator_context?: Record<string, any>;
// AI Intelligence
ai_reasoning?: AIReasoningContext;
confidence_score: number;
// Actions
// CRITICAL: Backend uses "smart_actions", NOT "actions"
smart_actions: SmartAction[];
// Entity References
// CRITICAL: Backend uses "entity_links", NOT "entity_refs"
entity_links: Record<string, string>;
// Timing Intelligence
timing_decision?: string;
scheduled_send_time?: string; // ISO date string
// Placement
placement_hints?: string[];
// Escalation & Chaining
action_created_at?: string; // ISO date string
superseded_by_action_id?: string;
hidden_from_ui?: boolean;
// Lifecycle
resolved_at?: string; // ISO date string
acknowledged_at?: string; // ISO date string
acknowledged_by?: string;
resolved_by?: string;
notes?: string;
assigned_to?: string;
}
/**
* Notification - Lightweight, ephemeral (7-day TTL)
*/
export interface Notification extends Event {
event_class: EventClass.NOTIFICATION;
// Notification-specific
notification_type: NotificationType;
// Entity Context (lightweight)
entity_type?: string; // 'batch', 'delivery', 'po', etc.
entity_id?: string;
old_state?: string;
new_state?: string;
// Placement
placement_hints?: string[];
// TTL
expires_at?: string; // ISO date string
}
/**
* Recommendation - Medium weight, dismissible
*/
export interface Recommendation extends Event {
event_class: EventClass.RECOMMENDATION;
// Recommendation-specific
recommendation_type: RecommendationType;
// Context (lighter than alerts)
estimated_impact?: Record<string, any>;
suggested_actions?: SmartAction[];
// AI Intelligence
ai_reasoning?: AIReasoningContext;
confidence_score?: number;
// Dismissal
dismissed_at?: string; // ISO date string
dismissed_by?: string;
}
/**
* Union type for all event responses
*/
export type EventResponse = Alert | Notification | Recommendation;
// ============================================================
// API RESPONSE WRAPPERS
// ============================================================
export interface PaginatedResponse<T> {
items: T[];
total: number;
limit: number;
offset: number;
has_next: boolean;
has_previous: boolean;
}
export interface EventsSummary {
total_count: number;
active_count: number;
critical_count: number;
high_count: number;
medium_count: number;
low_count: number;
resolved_count: number;
acknowledged_count: number;
}
export interface EventQueryParams {
priority_level?: PriorityLevel | string;
status?: AlertStatus | string;
resolved?: boolean;
event_class?: EventClass | string;
event_domain?: string;
limit?: number;
offset?: number;
}
// ============================================================
// TYPE GUARDS
// ============================================================
export function isAlert(event: EventResponse | Event): event is Alert {
return event.event_class === EventClass.ALERT || event.event_class === 'alert';
}
export function isNotification(event: EventResponse | Event): event is Notification {
return event.event_class === EventClass.NOTIFICATION || event.event_class === 'notification';
}
export function isRecommendation(event: EventResponse | Event): event is Recommendation {
return event.event_class === EventClass.RECOMMENDATION || event.event_class === 'recommendation';
}
// ============================================================
// HELPER FUNCTIONS
// ============================================================
export function getPriorityColor(level: PriorityLevel | string): string {
const levelValue = String(level);
if (levelValue === PriorityLevel.CRITICAL || levelValue === 'critical') {
return 'var(--color-error)';
} else if (levelValue === PriorityLevel.IMPORTANT || levelValue === 'important') {
return 'var(--color-warning)';
} else if (levelValue === PriorityLevel.STANDARD || levelValue === 'standard') {
return 'var(--color-info)';
} else if (levelValue === PriorityLevel.INFO || levelValue === 'info') {
return 'var(--color-success)';
} else {
return 'var(--color-info)';
}
}
export function getPriorityIcon(level: PriorityLevel | string): string {
const levelValue = String(level);
if (levelValue === PriorityLevel.CRITICAL || levelValue === 'critical') {
return 'alert-triangle';
} else if (levelValue === PriorityLevel.IMPORTANT || levelValue === 'important') {
return 'alert-circle';
} else if (levelValue === PriorityLevel.STANDARD || levelValue === 'standard') {
return 'info';
} else if (levelValue === PriorityLevel.INFO || levelValue === 'info') {
return 'check-circle';
} else {
return 'info';
}
}
export function getTypeClassBadgeVariant(
typeClass: AlertTypeClass | string
): 'default' | 'primary' | 'secondary' | 'success' | 'warning' | 'error' | 'info' | 'outline' {
// Convert to string and compare with known values
const typeValue = String(typeClass);
if (typeValue === AlertTypeClass.ACTION_NEEDED || typeValue === 'action_needed') {
return 'error';
} else if (typeValue === AlertTypeClass.PREVENTED_ISSUE || typeValue === 'prevented_issue') {
return 'success';
} else if (typeValue === AlertTypeClass.TREND_WARNING || typeValue === 'trend_warning') {
return 'warning';
} else if (typeValue === AlertTypeClass.ESCALATION || typeValue === 'escalation') {
return 'error';
} else if (typeValue === AlertTypeClass.INFORMATION || typeValue === 'information') {
return 'info';
} else {
return 'default';
}
}
export function formatTimeUntilConsequence(hours?: number): string {
if (!hours) return '';
if (hours < 1) {
return `${Math.round(hours * 60)} minutes`;
} else if (hours < 24) {
return `${Math.round(hours)} hours`;
} else {
return `${Math.round(hours / 24)} days`;
}
}
/**
* Convert legacy alert format to new Event format
* This function provides backward compatibility for older alert structures
*/
export function convertLegacyAlert(legacyAlert: any): Event {
// If it's already in the new format, return as-is
if (legacyAlert.event_class && legacyAlert.event_class in EventClass) {
return legacyAlert;
}
// Convert legacy format to new format
const newAlert: Event = {
id: legacyAlert.id || legacyAlert.alert_id || '',
tenant_id: legacyAlert.tenant_id || '',
event_class: EventClass.ALERT, // Default to alert
event_domain: legacyAlert.event_domain || '',
event_type: legacyAlert.event_type || legacyAlert.type || '',
service: legacyAlert.service || 'unknown',
i18n: legacyAlert.i18n || {
title_key: legacyAlert.title_key || legacyAlert.title || '',
message_key: legacyAlert.message_key || legacyAlert.message || '',
title_params: legacyAlert.title_params || {},
message_params: legacyAlert.message_params || {},
},
priority_level: legacyAlert.priority_level || PriorityLevel.STANDARD,
status: legacyAlert.status || 'active',
created_at: legacyAlert.created_at || new Date().toISOString(),
updated_at: legacyAlert.updated_at || new Date().toISOString(),
event_metadata: legacyAlert.event_metadata || legacyAlert.metadata || {},
};
return newAlert;
}

View File

@@ -0,0 +1,360 @@
// ================================================================
// frontend/src/api/types/external.ts
// ================================================================
/**
* External Data Type Definitions (Weather & Traffic)
*
* Aligned with backend schemas:
* - services/external/app/schemas/weather.py
* - services/external/app/schemas/traffic.py
*
* Last Updated: 2025-10-05
* Status: ✅ Complete - Zero drift with backend
*/
// ================================================================
// WEATHER TYPES
// ================================================================
/**
* Base weather data schema
* Backend: services/external/app/schemas/weather.py:9-20 (WeatherDataBase)
*/
export interface WeatherDataBase {
location_id: string; // max_length=100
date: string; // ISO datetime
temperature?: number | null; // ge=-50, le=60 - Celsius
precipitation?: number | null; // ge=0 - mm
humidity?: number | null; // ge=0, le=100 - percentage
wind_speed?: number | null; // ge=0, le=200 - km/h
pressure?: number | null; // ge=800, le=1200 - hPa
description?: string | null; // max_length=200
source: string; // max_length=50, default="aemet"
raw_data?: string | null;
}
/**
* Schema for creating weather data
* Backend: services/external/app/schemas/weather.py:22-24 (WeatherDataCreate)
*/
export interface WeatherDataCreate extends WeatherDataBase {}
/**
* Schema for updating weather data
* Backend: services/external/app/schemas/weather.py:26-34 (WeatherDataUpdate)
*/
export interface WeatherDataUpdate {
temperature?: number | null; // ge=-50, le=60
precipitation?: number | null; // ge=0
humidity?: number | null; // ge=0, le=100
wind_speed?: number | null; // ge=0, le=200
pressure?: number | null; // ge=800, le=1200
description?: string | null; // max_length=200
raw_data?: string | null;
}
/**
* Schema for weather data responses
* Backend: services/external/app/schemas/weather.py:36-53 (WeatherDataResponse)
* Note: Duplicate definition at 123-131, using the more complete one
*/
export interface WeatherDataResponse extends WeatherDataBase {
id: string;
created_at: string; // ISO datetime
updated_at: string; // ISO datetime
}
/**
* Base weather forecast schema
* Backend: services/external/app/schemas/weather.py:55-65 (WeatherForecastBase)
*/
export interface WeatherForecastBase {
location_id: string; // max_length=100
forecast_date: string; // ISO datetime
temperature?: number | null; // ge=-50, le=60
precipitation?: number | null; // ge=0
humidity?: number | null; // ge=0, le=100
wind_speed?: number | null; // ge=0, le=200
description?: string | null; // max_length=200
source: string; // max_length=50, default="aemet"
raw_data?: string | null;
}
/**
* Schema for creating weather forecasts
* Backend: services/external/app/schemas/weather.py:67-69 (WeatherForecastCreate)
*/
export interface WeatherForecastCreate extends WeatherForecastBase {}
/**
* Schema for weather forecast responses
* Backend: services/external/app/schemas/weather.py:71-89 (WeatherForecastResponse)
* Note: Duplicate definition at 133-141, using the more complete one
*/
export interface WeatherForecastResponse extends WeatherForecastBase {
id: string;
generated_at: string; // ISO datetime
created_at: string; // ISO datetime
updated_at: string; // ISO datetime
}
/**
* Schema for paginated weather data responses
* Backend: services/external/app/schemas/weather.py:91-98 (WeatherDataList)
*/
export interface WeatherDataList {
data: WeatherDataResponse[];
total: number;
page: number;
per_page: number;
has_next: boolean;
has_prev: boolean;
}
/**
* Schema for paginated weather forecast responses
* Backend: services/external/app/schemas/weather.py:100-105 (WeatherForecastList)
*/
export interface WeatherForecastList {
forecasts: WeatherForecastResponse[];
total: number;
page: number;
per_page: number;
}
/**
* Schema for weather analytics
* Backend: services/external/app/schemas/weather.py:107-121 (WeatherAnalytics)
*/
export interface WeatherAnalytics {
location_id: string;
period_start: string; // ISO datetime
period_end: string; // ISO datetime
avg_temperature?: number | null;
min_temperature?: number | null;
max_temperature?: number | null;
total_precipitation?: number | null;
avg_humidity?: number | null;
avg_wind_speed?: number | null;
avg_pressure?: number | null;
weather_conditions: Record<string, any>; // Default: {}
rainy_days: number; // Default: 0
sunny_days: number; // Default: 0
}
/**
* Location request for weather/traffic data
* Backend: services/external/app/schemas/weather.py:143-146 (LocationRequest)
*/
export interface LocationRequest {
latitude: number;
longitude: number;
address?: string | null;
}
/**
* Date range request
* Backend: services/external/app/schemas/weather.py:148-150 (DateRangeRequest)
*/
export interface DateRangeRequest {
start_date: string; // ISO datetime
end_date: string; // ISO datetime
}
/**
* Historical weather request
* Backend: services/external/app/schemas/weather.py:152-156 (HistoricalWeatherRequest)
*/
export interface HistoricalWeatherRequest {
latitude: number;
longitude: number;
start_date: string; // ISO datetime
end_date: string; // ISO datetime
}
/**
* Weather forecast request
* Backend: services/external/app/schemas/weather.py:158-161 (WeatherForecastRequest)
*/
export interface WeatherForecastRequest {
latitude: number;
longitude: number;
days: number;
}
/**
* Hourly forecast request
* Backend: services/external/app/schemas/weather.py:163-166 (HourlyForecastRequest)
*/
export interface HourlyForecastRequest {
latitude: number;
longitude: number;
hours?: number; // Default: 48, ge=1, le=48
}
/**
* Hourly forecast response
* Backend: services/external/app/schemas/weather.py:168-177 (HourlyForecastResponse)
*/
export interface HourlyForecastResponse {
forecast_datetime: string; // ISO datetime
generated_at: string; // ISO datetime
temperature?: number | null;
precipitation?: number | null;
humidity?: number | null;
wind_speed?: number | null;
description?: string | null;
source: string;
hour: number;
}
// ================================================================
// TRAFFIC TYPES
// ================================================================
/**
* Base traffic data schema
* Backend: services/external/app/schemas/traffic.py:11-20 (TrafficDataBase)
*/
export interface TrafficDataBase {
location_id: string; // max_length=100
date: string; // ISO datetime
traffic_volume?: number | null; // ge=0 - Vehicles per hour
pedestrian_count?: number | null; // ge=0 - Pedestrians per hour
congestion_level?: string | null; // pattern: ^(low|medium|high)$
average_speed?: number | null; // ge=0, le=200 - km/h
source: string; // max_length=50, default="madrid_opendata"
raw_data?: string | null;
}
/**
* Schema for creating traffic data
* Backend: services/external/app/schemas/traffic.py:22-24 (TrafficDataCreate)
*/
export interface TrafficDataCreate extends TrafficDataBase {}
/**
* Schema for updating traffic data
* Backend: services/external/app/schemas/traffic.py:26-32 (TrafficDataUpdate)
*/
export interface TrafficDataUpdate {
traffic_volume?: number | null; // ge=0
pedestrian_count?: number | null; // ge=0
congestion_level?: string | null; // pattern: ^(low|medium|high)$
average_speed?: number | null; // ge=0, le=200
raw_data?: string | null;
}
/**
* Schema for traffic data responses from database
* Backend: services/external/app/schemas/traffic.py:34-51 (TrafficDataResponseDB)
*/
export interface TrafficDataResponseDB extends TrafficDataBase {
id: string;
created_at: string; // ISO datetime
updated_at: string; // ISO datetime
}
/**
* Schema for API traffic data responses
* Backend: services/external/app/schemas/traffic.py:74-86 (TrafficDataResponse)
*/
export interface TrafficDataResponse {
date: string; // ISO datetime
traffic_volume?: number | null; // ge=0
pedestrian_count?: number | null; // ge=0
congestion_level?: string | null; // pattern: ^(low|medium|high)$
average_speed?: number | null; // ge=0, le=200
source: string;
}
/**
* Schema for paginated traffic data responses
* Backend: services/external/app/schemas/traffic.py:53-60 (TrafficDataList)
*/
export interface TrafficDataList {
data: TrafficDataResponseDB[];
total: number;
page: number;
per_page: number;
has_next: boolean;
has_prev: boolean;
}
/**
* Schema for traffic analytics
* Backend: services/external/app/schemas/traffic.py:62-72 (TrafficAnalytics)
*/
export interface TrafficAnalytics {
location_id: string;
period_start: string; // ISO datetime
period_end: string; // ISO datetime
avg_traffic_volume?: number | null;
avg_pedestrian_count?: number | null;
peak_traffic_hour?: number | null;
peak_pedestrian_hour?: number | null;
congestion_distribution: Record<string, any>; // Default: {}
avg_speed?: number | null;
}
/**
* Historical traffic request
* Backend: services/external/app/schemas/traffic.py:97-101 (HistoricalTrafficRequest)
*/
export interface HistoricalTrafficRequest {
latitude: number;
longitude: number;
start_date: string; // ISO datetime
end_date: string; // ISO datetime
}
/**
* Traffic forecast request
* Backend: services/external/app/schemas/traffic.py:103-106 (TrafficForecastRequest)
*/
export interface TrafficForecastRequest {
latitude: number;
longitude: number;
hours?: number; // Default: 24
}
// ================================================================
// CITY-BASED DATA TYPES (NEW)
// ================================================================
/**
* City information response
* Backend: services/external/app/schemas/city_data.py:CityInfoResponse
*/
export interface CityInfoResponse {
city_id: string;
name: string;
country: string;
latitude: number;
longitude: number;
radius_km: number;
weather_provider: string;
traffic_provider: string;
enabled: boolean;
}
/**
* Data availability response
* Backend: services/external/app/schemas/city_data.py:DataAvailabilityResponse
*/
export interface DataAvailabilityResponse {
city_id: string;
city_name: string;
// Weather availability
weather_available: boolean;
weather_start_date: string | null;
weather_end_date: string | null;
weather_record_count: number;
// Traffic availability
traffic_available: boolean;
traffic_start_date: string | null;
traffic_end_date: string | null;
traffic_record_count: number;
}

View File

@@ -0,0 +1,272 @@
/**
* Food Safety API Types - Mirror backend schemas
*/
// Food Safety Enums
export enum FoodSafetyStandard {
HACCP = 'haccp',
FDA = 'fda',
USDA = 'usda',
FSMA = 'fsma',
SQF = 'sqf',
BRC = 'brc',
IFS = 'ifs',
ISO22000 = 'iso22000',
ORGANIC = 'organic',
NON_GMO = 'non_gmo',
ALLERGEN_FREE = 'allergen_free',
KOSHER = 'kosher',
HALAL = 'halal'
}
export enum ComplianceStatus {
COMPLIANT = 'compliant',
NON_COMPLIANT = 'non_compliant',
PENDING_REVIEW = 'pending_review',
EXPIRED = 'expired',
WARNING = 'warning'
}
export enum FoodSafetyAlertType {
TEMPERATURE_VIOLATION = 'temperature_violation',
EXPIRATION_WARNING = 'expiration_warning',
EXPIRED_PRODUCT = 'expired_product',
CONTAMINATION_RISK = 'contamination_risk',
ALLERGEN_CROSS_CONTAMINATION = 'allergen_cross_contamination',
STORAGE_VIOLATION = 'storage_violation',
QUALITY_DEGRADATION = 'quality_degradation',
RECALL_NOTICE = 'recall_notice',
CERTIFICATION_EXPIRY = 'certification_expiry',
SUPPLIER_COMPLIANCE_ISSUE = 'supplier_compliance_issue'
}
export interface FoodSafetyComplianceCreate {
ingredient_id: string;
compliance_type: 'temperature_check' | 'quality_inspection' | 'hygiene_audit' | 'batch_verification';
status: 'pass' | 'fail' | 'warning';
temperature?: number;
humidity?: number;
ph_level?: number;
visual_inspection_notes?: string;
corrective_actions?: string;
inspector_name?: string;
certification_reference?: string;
notes?: string;
}
export interface FoodSafetyComplianceUpdate {
compliance_type?: 'temperature_check' | 'quality_inspection' | 'hygiene_audit' | 'batch_verification';
status?: 'pass' | 'fail' | 'warning';
temperature?: number;
humidity?: number;
ph_level?: number;
visual_inspection_notes?: string;
corrective_actions?: string;
inspector_name?: string;
certification_reference?: string;
notes?: string;
resolved?: boolean;
}
export interface FoodSafetyComplianceResponse {
id: string;
tenant_id: string;
ingredient_id: string;
ingredient_name: string;
compliance_type: 'temperature_check' | 'quality_inspection' | 'hygiene_audit' | 'batch_verification';
status: 'pass' | 'fail' | 'warning';
temperature?: number;
humidity?: number;
ph_level?: number;
visual_inspection_notes?: string;
corrective_actions?: string;
inspector_name?: string;
certification_reference?: string;
notes?: string;
resolved: boolean;
created_at: string;
updated_at: string;
created_by?: string;
}
export interface TemperatureLogCreate {
location: string;
temperature: number;
humidity?: number;
equipment_id?: string;
notes?: string;
}
export interface BulkTemperatureLogCreate {
logs: TemperatureLogCreate[];
}
export interface TemperatureLogResponse {
id: string;
tenant_id: string;
location: string;
temperature: number;
humidity?: number;
equipment_id?: string;
notes?: string;
is_within_range: boolean;
alert_triggered: boolean;
created_at: string;
created_by?: string;
}
export interface FoodSafetyAlertCreate {
alert_type: 'temperature_violation' | 'expiry_warning' | 'quality_issue' | 'compliance_failure';
severity: 'critical' | 'warning' | 'info';
title: string;
description: string;
ingredient_id?: string;
temperature_log_id?: string;
compliance_record_id?: string;
requires_action: boolean;
assigned_to?: string;
}
export interface FoodSafetyAlertUpdate {
status?: 'open' | 'in_progress' | 'resolved' | 'dismissed';
assigned_to?: string;
resolution_notes?: string;
corrective_actions?: string;
}
export interface FoodSafetyAlertResponse {
id: string;
tenant_id: string;
alert_type: 'temperature_violation' | 'expiry_warning' | 'quality_issue' | 'compliance_failure';
severity: 'critical' | 'warning' | 'info';
title: string;
description: string;
status: 'open' | 'in_progress' | 'resolved' | 'dismissed';
ingredient_id?: string;
ingredient_name?: string;
temperature_log_id?: string;
compliance_record_id?: string;
requires_action: boolean;
assigned_to?: string;
assigned_to_name?: string;
resolution_notes?: string;
corrective_actions?: string;
created_at: string;
updated_at: string;
resolved_at?: string;
created_by?: string;
}
export interface FoodSafetyFilter {
compliance_type?: 'temperature_check' | 'quality_inspection' | 'hygiene_audit' | 'batch_verification';
status?: 'pass' | 'fail' | 'warning';
ingredient_id?: string;
resolved?: boolean;
date_range?: {
start: string;
end: string;
};
limit?: number;
offset?: number;
order_by?: string;
order_direction?: 'asc' | 'desc';
}
export interface TemperatureMonitoringFilter {
location?: string;
equipment_id?: string;
temperature_range?: {
min: number;
max: number;
};
alert_triggered?: boolean;
date_range?: {
start: string;
end: string;
};
limit?: number;
offset?: number;
order_by?: string;
order_direction?: 'asc' | 'desc';
}
export interface FoodSafetyMetrics {
tenant_id: string;
period_start: string;
period_end: string;
total_compliance_checks: number;
passed_checks: number;
failed_checks: number;
warning_checks: number;
compliance_rate: number;
total_temperature_logs: number;
temperature_violations: number;
critical_alerts: number;
resolved_alerts: number;
average_resolution_time_hours: number;
top_risk_ingredients: Array<{
ingredient_name: string;
risk_score: number;
incident_count: number;
}>;
}
export interface TemperatureAnalytics {
tenant_id: string;
location: string;
period_start: string;
period_end: string;
average_temperature: number;
min_temperature: number;
max_temperature: number;
temperature_trend: Array<{
timestamp: string;
temperature: number;
humidity?: number;
}>;
violations_count: number;
uptime_percentage: number;
}
export interface FoodSafetyDashboard {
tenant_id: string;
compliance_summary: {
total_checks: number;
passed: number;
failed: number;
warnings: number;
compliance_rate: number;
};
temperature_monitoring: {
total_logs: number;
violations: number;
locations_monitored: number;
latest_readings: TemperatureLogResponse[];
};
active_alerts: {
critical: number;
warning: number;
info: number;
overdue: number;
};
recent_activities: Array<{
type: string;
description: string;
timestamp: string;
severity?: string;
}>;
upcoming_expirations: Array<{
ingredient_name: string;
expiration_date: string;
days_until_expiry: number;
quantity: number;
}>;
}
// Select option interface for enum helpers
export interface EnumOption {
value: string | number;
label: string;
disabled?: boolean;
description?: string;
}

View File

@@ -0,0 +1,424 @@
/**
* TypeScript types for Forecasting service
* Mirrored from backend schemas: services/forecasting/app/schemas/forecasts.py
*
* Coverage:
* - Forecast CRUD (list, get, delete)
* - Forecast Operations (single, multi-day, batch, realtime predictions)
* - Analytics (performance metrics)
* - Validation operations
*/
// ================================================================
// ENUMS
// ================================================================
/**
* Business type enumeration
* Backend: BusinessType enum in schemas/forecasts.py (lines 13-15)
*/
export enum BusinessType {
INDIVIDUAL = 'individual',
CENTRAL_WORKSHOP = 'central_workshop'
}
// ================================================================
// REQUEST TYPES
// ================================================================
/**
* Request schema for generating forecasts
* Backend: ForecastRequest in schemas/forecasts.py (lines 18-33)
*/
export interface ForecastRequest {
inventory_product_id: string; // Inventory product UUID reference
forecast_date: string; // ISO date string - cannot be in the past
forecast_days?: number; // Default: 1, ge=1, le=30
location: string; // Location identifier
confidence_level?: number; // Default: 0.8, ge=0.5, le=0.95
}
/**
* Request schema for batch forecasting
* Backend: BatchForecastRequest in schemas/forecasts.py (lines 35-41)
*/
export interface BatchForecastRequest {
tenant_id: string;
batch_name: string;
inventory_product_ids: string[];
forecast_days?: number; // Default: 7, ge=1, le=30
}
// ================================================================
// RESPONSE TYPES
// ================================================================
/**
* Response schema for forecast results
* Backend: ForecastResponse in schemas/forecasts.py (lines 42-77)
*/
export interface ForecastResponse {
id: string;
tenant_id: string;
inventory_product_id: string; // Reference to inventory service
location: string;
forecast_date: string; // ISO datetime string
// Predictions
predicted_demand: number;
confidence_lower: number;
confidence_upper: number;
confidence_level: number;
// Model info
model_id: string;
model_version: string;
algorithm: string;
// Context
business_type: string;
is_holiday: boolean;
is_weekend: boolean;
day_of_week: number;
// External factors (optional)
weather_temperature?: number | null;
weather_precipitation?: number | null;
weather_description?: string | null;
traffic_volume?: number | null;
// Metadata
created_at: string; // ISO datetime string
processing_time_ms?: number | null;
features?: Record<string, any> | null;
}
/**
* Response schema for batch forecast requests
* Backend: BatchForecastResponse in schemas/forecasts.py (lines 79-96)
*/
export interface BatchForecastResponse {
id: string;
tenant_id: string;
batch_name: string;
status: string;
total_products: number;
completed_products: number;
failed_products: number;
// Timing
requested_at: string; // ISO datetime string
completed_at?: string | null; // ISO datetime string
processing_time_ms?: number | null;
// Results
forecasts?: ForecastResponse[] | null;
error_message?: string | null;
}
/**
* Response schema for multi-day forecast results
* Backend: MultiDayForecastResponse in schemas/forecasts.py (lines 98-107)
*/
export interface MultiDayForecastResponse {
tenant_id: string;
inventory_product_id: string;
forecast_start_date: string; // ISO date string
forecast_days: number;
forecasts: ForecastResponse[];
total_predicted_demand: number;
average_confidence_level: number;
processing_time_ms: number;
}
// ================================================================
// OPERATIONS TYPES
// ================================================================
/**
* Real-time prediction request
* Backend: generate_realtime_prediction endpoint in api/forecasting_operations.py (lines 218-288)
*/
export interface RealtimePredictionRequest {
inventory_product_id: string;
model_id: string;
model_path?: string;
features: Record<string, any>;
confidence_level?: number; // Default: 0.8
}
/**
* Real-time prediction response
* Backend: generate_realtime_prediction endpoint return value (lines 262-269)
*/
export interface RealtimePredictionResponse {
tenant_id: string;
inventory_product_id: string;
model_id: string;
prediction: any;
confidence: any;
timestamp: string; // ISO datetime string
}
/**
* Batch predictions response
* Backend: generate_batch_predictions endpoint return value (lines 291-333)
*/
export interface BatchPredictionsResponse {
predictions: Array<{
inventory_product_id?: string;
prediction?: any;
confidence?: any;
success: boolean;
error?: string;
}>;
total: number;
}
/**
* Prediction validation result
* Backend: validate_predictions endpoint in api/forecasting_operations.py (lines 336-362)
*/
export interface PredictionValidationResult {
// Response structure from enhanced_forecasting_service.validate_predictions
[key: string]: any;
}
/**
* Forecast statistics response
* Backend: get_forecast_statistics endpoint in api/forecasting_operations.py (lines 365-391)
*/
export interface ForecastStatisticsResponse {
// Response structure from enhanced_forecasting_service.get_forecast_statistics
[key: string]: any;
}
// ================================================================
// ANALYTICS TYPES
// ================================================================
/**
* Predictions performance analytics
* Backend: get_predictions_performance endpoint in api/analytics.py (lines 27-53)
*/
export interface PredictionsPerformanceResponse {
// Response structure from prediction_service.get_performance_metrics
[key: string]: any;
}
// ================================================================
// QUERY PARAMETERS
// ================================================================
/**
* Query parameters for listing forecasts
* Backend: list_forecasts endpoint in api/forecasts.py (lines 29-62)
*/
export interface ListForecastsParams {
inventory_product_id?: string | null;
start_date?: string | null; // ISO date string
end_date?: string | null; // ISO date string
limit?: number; // Default: 50, ge=1, le=1000
offset?: number; // Default: 0, ge=0
}
/**
* Query parameters for validation operations
* Backend: validate_predictions endpoint query params (lines 336-362)
*/
export interface ValidationQueryParams {
start_date: string; // ISO date string - required
end_date: string; // ISO date string - required
}
/**
* Query parameters for forecast statistics
* Backend: get_forecast_statistics endpoint query params (lines 365-391)
*/
export interface ForecastStatisticsParams {
start_date?: string | null; // ISO date string
end_date?: string | null; // ISO date string
}
/**
* Query parameters for predictions performance
* Backend: get_predictions_performance endpoint query params (lines 27-53)
*/
export interface PredictionsPerformanceParams {
start_date?: string | null; // ISO date string
end_date?: string | null; // ISO date string
}
// ================================================================
// GENERIC RESPONSE TYPES
// ================================================================
/**
* Generic message response for operations
* Used by: delete_forecast, clear_prediction_cache
*/
export interface MessageResponse {
message: string;
}
// ================================================================
// SCENARIO SIMULATION TYPES - PROFESSIONAL/ENTERPRISE ONLY
// ================================================================
/**
* Types of scenarios available for simulation
* Backend: ScenarioType enum in schemas/forecasts.py (lines 114-123)
*/
export enum ScenarioType {
WEATHER = 'weather',
COMPETITION = 'competition',
EVENT = 'event',
PRICING = 'pricing',
PROMOTION = 'promotion',
HOLIDAY = 'holiday',
SUPPLY_DISRUPTION = 'supply_disruption',
CUSTOM = 'custom'
}
/**
* Weather scenario parameters
* Backend: WeatherScenario in schemas/forecasts.py (lines 126-130)
*/
export interface WeatherScenario {
temperature_change?: number | null; // Temperature change in °C (-30 to +30)
precipitation_change?: number | null; // Precipitation change in mm (0-100)
weather_type?: string | null; // Weather type (heatwave, cold_snap, rainy, etc.)
}
/**
* Competition scenario parameters
* Backend: CompetitionScenario in schemas/forecasts.py (lines 133-137)
*/
export interface CompetitionScenario {
new_competitors: number; // Number of new competitors (1-10)
distance_km: number; // Distance from location in km (0.1-10)
estimated_market_share_loss: number; // Estimated market share loss (0-0.5)
}
/**
* Event scenario parameters
* Backend: EventScenario in schemas/forecasts.py (lines 140-145)
*/
export interface EventScenario {
event_type: string; // Type of event (festival, sports, concert, etc.)
expected_attendance: number; // Expected attendance
distance_km: number; // Distance from location in km (0-50)
duration_days: number; // Duration in days (1-30)
}
/**
* Pricing scenario parameters
* Backend: PricingScenario in schemas/forecasts.py (lines 148-151)
*/
export interface PricingScenario {
price_change_percent: number; // Price change percentage (-50 to +100)
affected_products?: string[] | null; // List of affected product IDs
}
/**
* Promotion scenario parameters
* Backend: PromotionScenario in schemas/forecasts.py (lines 154-158)
*/
export interface PromotionScenario {
discount_percent: number; // Discount percentage (0-75)
promotion_type: string; // Type of promotion (bogo, discount, bundle, etc.)
expected_traffic_increase: number; // Expected traffic increase (0-2.0 = 0-200%)
}
/**
* Request schema for scenario simulation
* Backend: ScenarioSimulationRequest in schemas/forecasts.py (lines 161-189)
*/
export interface ScenarioSimulationRequest {
scenario_name: string; // Name for this scenario (3-200 chars)
scenario_type: ScenarioType;
inventory_product_ids: string[]; // Products to simulate (min 1)
start_date: string; // ISO date string
duration_days?: number; // Default: 7, range: 1-30
// Scenario-specific parameters (provide based on scenario_type)
weather_params?: WeatherScenario | null;
competition_params?: CompetitionScenario | null;
event_params?: EventScenario | null;
pricing_params?: PricingScenario | null;
promotion_params?: PromotionScenario | null;
// Custom scenario parameters
custom_multipliers?: Record<string, number> | null;
// Comparison settings
include_baseline?: boolean; // Default: true
}
/**
* Impact of scenario on a specific product
* Backend: ScenarioImpact in schemas/forecasts.py (lines 192-199)
*/
export interface ScenarioImpact {
inventory_product_id: string;
baseline_demand: number;
simulated_demand: number;
demand_change_percent: number;
confidence_range: [number, number];
impact_factors: Record<string, any>;
}
/**
* Response schema for scenario simulation
* Backend: ScenarioSimulationResponse in schemas/forecasts.py (lines 202-256)
*/
export interface ScenarioSimulationResponse {
id: string;
tenant_id: string;
scenario_name: string;
scenario_type: ScenarioType;
// Simulation parameters
start_date: string; // ISO date string
end_date: string; // ISO date string
duration_days: number;
// Results
baseline_forecasts?: ForecastResponse[] | null;
scenario_forecasts: ForecastResponse[];
// Impact summary
total_baseline_demand: number;
total_scenario_demand: number;
overall_impact_percent: number;
product_impacts: ScenarioImpact[];
// Insights and recommendations
insights: string[];
recommendations: string[];
risk_level: string; // low, medium, high
// Metadata
created_at: string; // ISO datetime string
processing_time_ms: number;
}
/**
* Request to compare multiple scenarios
* Backend: ScenarioComparisonRequest in schemas/forecasts.py (lines 259-261)
*/
export interface ScenarioComparisonRequest {
scenario_ids: string[]; // 2-5 scenario IDs to compare
}
/**
* Response comparing multiple scenarios
* Backend: ScenarioComparisonResponse in schemas/forecasts.py (lines 264-270)
*/
export interface ScenarioComparisonResponse {
scenarios: ScenarioSimulationResponse[];
comparison_matrix: Record<string, Record<string, any>>;
best_case_scenario_id: string;
worst_case_scenario_id: string;
recommended_action: string;
}

Some files were not shown because too many files have changed in this diff Show More