Initial commit - production deployment
This commit is contained in:
41
frontend/.dockerignore
Normal file
41
frontend/.dockerignore
Normal 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
25
frontend/.eslintrc.json
Normal 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
41
frontend/.gitignore
vendored
Normal 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
9
frontend/.prettierrc
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"semi": true,
|
||||
"trailingComma": "es5",
|
||||
"singleQuote": true,
|
||||
"printWidth": 100,
|
||||
"tabWidth": 2,
|
||||
"useTabs": false,
|
||||
"plugins": ["prettier-plugin-tailwindcss"]
|
||||
}
|
||||
85
frontend/Dockerfile.kubernetes
Normal file
85
frontend/Dockerfile.kubernetes
Normal 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;"]
|
||||
|
||||
89
frontend/Dockerfile.kubernetes.debug
Normal file
89
frontend/Dockerfile.kubernetes.debug
Normal 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
141
frontend/E2E_TESTING.md
Normal 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)
|
||||
333
frontend/PLAYWRIGHT_SETUP_COMPLETE.md
Normal file
333
frontend/PLAYWRIGHT_SETUP_COMPLETE.md
Normal 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
763
frontend/README.md
Normal 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.
|
||||
476
frontend/TESTING_ONBOARDING_GUIDE.md
Normal file
476
frontend/TESTING_ONBOARDING_GUIDE.md
Normal 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.
|
||||
195
frontend/TEST_COMMANDS_QUICK_REFERENCE.md
Normal file
195
frontend/TEST_COMMANDS_QUICK_REFERENCE.md
Normal 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
30
frontend/index.html
Normal 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
12
frontend/nginx-main.conf
Normal 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
122
frontend/nginx.conf
Normal 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
17631
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
120
frontend/package.json
Normal file
120
frontend/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
110
frontend/playwright.config.ts
Normal file
110
frontend/playwright.config.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
119
frontend/playwright.k8s.config.ts
Normal file
119
frontend/playwright.k8s.config.ts
Normal 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,
|
||||
});
|
||||
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
1
frontend/public/favicon.ico
Normal file
1
frontend/public/favicon.ico
Normal 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 |
114
frontend/public/manifest.json
Normal file
114
frontend/public/manifest.json
Normal 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
139
frontend/public/sw.js
Normal 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
124
frontend/src/App.tsx
Normal 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;
|
||||
597
frontend/src/api/client/apiClient.ts
Normal file
597
frontend/src/api/client/apiClient.ts
Normal 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;
|
||||
2
frontend/src/api/client/index.ts
Normal file
2
frontend/src/api/client/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { apiClient, default } from './apiClient';
|
||||
export type { ApiError } from './apiClient';
|
||||
303
frontend/src/api/hooks/aiInsights.ts
Normal file
303
frontend/src/api/hooks/aiInsights.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
115
frontend/src/api/hooks/auditLogs.ts
Normal file
115
frontend/src/api/hooks/auditLogs.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
215
frontend/src/api/hooks/auth.ts
Normal file
215
frontend/src/api/hooks/auth.ts
Normal 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,
|
||||
});
|
||||
};
|
||||
84
frontend/src/api/hooks/enterprise.ts
Normal file
84
frontend/src/api/hooks/enterprise.ts
Normal 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,
|
||||
});
|
||||
};
|
||||
184
frontend/src/api/hooks/equipment.ts
Normal file
184
frontend/src/api/hooks/equipment.ts
Normal 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
|
||||
});
|
||||
}
|
||||
316
frontend/src/api/hooks/forecasting.ts
Normal file
316
frontend/src/api/hooks/forecasting.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
};
|
||||
};
|
||||
802
frontend/src/api/hooks/inventory.ts
Normal file
802
frontend/src/api/hooks/inventory.ts
Normal 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,
|
||||
});
|
||||
};
|
||||
250
frontend/src/api/hooks/onboarding.ts
Normal file
250
frontend/src/api/hooks/onboarding.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
158
frontend/src/api/hooks/orchestrator.ts
Normal file
158
frontend/src/api/hooks/orchestrator.ts
Normal 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,
|
||||
});
|
||||
};
|
||||
368
frontend/src/api/hooks/orders.ts
Normal file
368
frontend/src/api/hooks/orders.ts
Normal 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),
|
||||
});
|
||||
},
|
||||
};
|
||||
};
|
||||
1103
frontend/src/api/hooks/performance.ts
Normal file
1103
frontend/src/api/hooks/performance.ts
Normal file
File diff suppressed because it is too large
Load Diff
687
frontend/src/api/hooks/pos.ts
Normal file
687
frontend/src/api/hooks/pos.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
495
frontend/src/api/hooks/procurement.ts
Normal file
495
frontend/src/api/hooks/procurement.ts
Normal 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,
|
||||
});
|
||||
};
|
||||
282
frontend/src/api/hooks/production.ts
Normal file
282
frontend/src/api/hooks/production.ts
Normal 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,
|
||||
});
|
||||
};
|
||||
325
frontend/src/api/hooks/purchase-orders.ts
Normal file
325
frontend/src/api/hooks/purchase-orders.ts
Normal 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,
|
||||
});
|
||||
};
|
||||
275
frontend/src/api/hooks/qualityTemplates.ts
Normal file
275
frontend/src/api/hooks/qualityTemplates.ts
Normal 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);
|
||||
},
|
||||
});
|
||||
}
|
||||
313
frontend/src/api/hooks/recipes.ts
Normal file
313
frontend/src/api/hooks/recipes.ts
Normal 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,
|
||||
});
|
||||
};
|
||||
215
frontend/src/api/hooks/sales.ts
Normal file
215
frontend/src/api/hooks/sales.ts
Normal 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,
|
||||
});
|
||||
};
|
||||
135
frontend/src/api/hooks/settings.ts
Normal file
135
frontend/src/api/hooks/settings.ts
Normal 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');
|
||||
},
|
||||
});
|
||||
};
|
||||
194
frontend/src/api/hooks/subscription.ts
Normal file
194
frontend/src/api/hooks/subscription.ts
Normal 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;
|
||||
682
frontend/src/api/hooks/suppliers.ts
Normal file
682
frontend/src/api/hooks/suppliers.ts
Normal 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;
|
||||
};
|
||||
123
frontend/src/api/hooks/sustainability.ts
Normal file
123
frontend/src/api/hooks/sustainability.ts
Normal 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 });
|
||||
},
|
||||
});
|
||||
}
|
||||
392
frontend/src/api/hooks/tenant.ts
Normal file
392
frontend/src/api/hooks/tenant.ts
Normal 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,
|
||||
});
|
||||
};
|
||||
707
frontend/src/api/hooks/training.ts
Normal file
707
frontend/src/api/hooks/training.ts
Normal 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',
|
||||
};
|
||||
};
|
||||
354
frontend/src/api/hooks/useAlerts.ts
Normal file
354
frontend/src/api/hooks/useAlerts.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
526
frontend/src/api/hooks/useControlPanelData.ts
Normal file
526
frontend/src/api/hooks/useControlPanelData.ts
Normal 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]);
|
||||
}
|
||||
452
frontend/src/api/hooks/useEnterpriseDashboard.ts
Normal file
452
frontend/src/api/hooks/useEnterpriseDashboard.ts
Normal 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,
|
||||
});
|
||||
};
|
||||
97
frontend/src/api/hooks/useInventoryStatus.ts
Normal file
97
frontend/src/api/hooks/useInventoryStatus.ts
Normal 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,
|
||||
});
|
||||
};
|
||||
79
frontend/src/api/hooks/usePremises.ts
Normal file
79
frontend/src/api/hooks/usePremises.ts
Normal 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,
|
||||
});
|
||||
};
|
||||
81
frontend/src/api/hooks/useProductionBatches.ts
Normal file
81
frontend/src/api/hooks/useProductionBatches.ts
Normal 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,
|
||||
});
|
||||
};
|
||||
1648
frontend/src/api/hooks/useProfessionalDashboard.ts
Normal file
1648
frontend/src/api/hooks/useProfessionalDashboard.ts
Normal file
File diff suppressed because it is too large
Load Diff
154
frontend/src/api/hooks/useUnifiedAlerts.ts
Normal file
154
frontend/src/api/hooks/useUnifiedAlerts.ts
Normal 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 };
|
||||
}
|
||||
126
frontend/src/api/hooks/user.ts
Normal file
126
frontend/src/api/hooks/user.ts
Normal 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
785
frontend/src/api/index.ts
Normal 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
|
||||
|
||||
452
frontend/src/api/services/aiInsights.ts
Normal file
452
frontend/src/api/services/aiInsights.ts
Normal 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();
|
||||
253
frontend/src/api/services/alertService.ts
Normal file
253
frontend/src/api/services/alertService.ts
Normal 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;
|
||||
125
frontend/src/api/services/alert_analytics.ts
Normal file
125
frontend/src/api/services/alert_analytics.ts
Normal 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 }
|
||||
}
|
||||
);
|
||||
}
|
||||
267
frontend/src/api/services/auditLogs.ts
Normal file
267
frontend/src/api/services/auditLogs.ts
Normal 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();
|
||||
258
frontend/src/api/services/auth.ts
Normal file
258
frontend/src/api/services/auth.ts
Normal 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();
|
||||
88
frontend/src/api/services/consent.ts
Normal file
88
frontend/src/api/services/consent.ts
Normal 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();
|
||||
204
frontend/src/api/services/demo.ts
Normal file
204
frontend/src/api/services/demo.ts
Normal 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();
|
||||
68
frontend/src/api/services/distribution.ts
Normal file
68
frontend/src/api/services/distribution.ts
Normal 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;
|
||||
281
frontend/src/api/services/equipment.ts
Normal file
281
frontend/src/api/services/equipment.ts
Normal 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();
|
||||
123
frontend/src/api/services/external.ts
Normal file
123
frontend/src/api/services/external.ts
Normal 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;
|
||||
317
frontend/src/api/services/forecasting.ts
Normal file
317
frontend/src/api/services/forecasting.ts
Normal 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;
|
||||
544
frontend/src/api/services/inventory.ts
Normal file
544
frontend/src/api/services/inventory.ts
Normal 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();
|
||||
106
frontend/src/api/services/nominatim.ts
Normal file
106
frontend/src/api/services/nominatim.ts
Normal 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;
|
||||
244
frontend/src/api/services/onboarding.ts
Normal file
244
frontend/src/api/services/onboarding.ts
Normal 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();
|
||||
254
frontend/src/api/services/orchestrator.ts
Normal file
254
frontend/src/api/services/orchestrator.ts
Normal 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';
|
||||
}
|
||||
}
|
||||
204
frontend/src/api/services/orders.ts
Normal file
204
frontend/src/api/services/orders.ts
Normal 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;
|
||||
597
frontend/src/api/services/pos.ts
Normal file
597
frontend/src/api/services/pos.ts
Normal 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;
|
||||
468
frontend/src/api/services/procurement-service.ts
Normal file
468
frontend/src/api/services/procurement-service.ts
Normal 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;
|
||||
445
frontend/src/api/services/production.ts
Normal file
445
frontend/src/api/services/production.ts
Normal 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;
|
||||
345
frontend/src/api/services/purchase_orders.ts
Normal file
345
frontend/src/api/services/purchase_orders.ts
Normal 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
|
||||
);
|
||||
}
|
||||
205
frontend/src/api/services/qualityTemplates.ts
Normal file
205
frontend/src/api/services/qualityTemplates.ts
Normal 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();
|
||||
225
frontend/src/api/services/recipes.ts
Normal file
225
frontend/src/api/services/recipes.ts
Normal 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;
|
||||
294
frontend/src/api/services/sales.ts
Normal file
294
frontend/src/api/services/sales.ts
Normal 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();
|
||||
152
frontend/src/api/services/settings.ts
Normal file
152
frontend/src/api/services/settings.ts
Normal 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;
|
||||
569
frontend/src/api/services/subscription.ts
Normal file
569
frontend/src/api/services/subscription.ts
Normal 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();
|
||||
478
frontend/src/api/services/suppliers.ts
Normal file
478
frontend/src/api/services/suppliers.ts
Normal 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;
|
||||
617
frontend/src/api/services/sustainability.ts
Normal file
617
frontend/src/api/services/sustainability.ts
Normal 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
|
||||
}
|
||||
};
|
||||
}
|
||||
264
frontend/src/api/services/tenant.ts
Normal file
264
frontend/src/api/services/tenant.ts
Normal 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();
|
||||
198
frontend/src/api/services/training.ts
Normal file
198
frontend/src/api/services/training.ts
Normal 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;
|
||||
49
frontend/src/api/services/user.ts
Normal file
49
frontend/src/api/services/user.ts
Normal 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();
|
||||
84
frontend/src/api/types/auditLogs.ts
Normal file
84
frontend/src/api/types/auditLogs.ts
Normal 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];
|
||||
368
frontend/src/api/types/auth.ts
Normal file
368
frontend/src/api/types/auth.ts
Normal 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";
|
||||
}
|
||||
}
|
||||
35
frontend/src/api/types/classification.ts
Normal file
35
frontend/src/api/types/classification.ts
Normal 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;
|
||||
};
|
||||
}
|
||||
111
frontend/src/api/types/dashboard.ts
Normal file
111
frontend/src/api/types/dashboard.ts
Normal 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;
|
||||
}
|
||||
69
frontend/src/api/types/dataImport.ts
Normal file
69
frontend/src/api/types/dataImport.ts
Normal 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[];
|
||||
}
|
||||
146
frontend/src/api/types/demo.ts
Normal file
146
frontend/src/api/types/demo.ts
Normal 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;
|
||||
}
|
||||
173
frontend/src/api/types/equipment.ts
Normal file
173
frontend/src/api/types/equipment.ts
Normal 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;
|
||||
}
|
||||
448
frontend/src/api/types/events.ts
Normal file
448
frontend/src/api/types/events.ts
Normal 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;
|
||||
}
|
||||
360
frontend/src/api/types/external.ts
Normal file
360
frontend/src/api/types/external.ts
Normal 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;
|
||||
}
|
||||
272
frontend/src/api/types/foodSafety.ts
Normal file
272
frontend/src/api/types/foodSafety.ts
Normal 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;
|
||||
}
|
||||
424
frontend/src/api/types/forecasting.ts
Normal file
424
frontend/src/api/types/forecasting.ts
Normal 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
Reference in New Issue
Block a user