Improve the frontend 4
This commit is contained in:
363
docs/ROLES_AND_PERMISSIONS_SYSTEM.md
Normal file
363
docs/ROLES_AND_PERMISSIONS_SYSTEM.md
Normal file
@@ -0,0 +1,363 @@
|
||||
# Roles and Permissions System
|
||||
|
||||
## Overview
|
||||
|
||||
The Bakery IA platform implements a **dual role system** that provides fine-grained access control across both platform-wide and organization-specific operations.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Two Distinct Role Systems
|
||||
|
||||
#### 1. Global User Roles (Auth Service)
|
||||
|
||||
**Purpose:** System-wide permissions across the entire platform
|
||||
**Service:** Auth Service
|
||||
**Storage:** `User` model
|
||||
**Scope:** Cross-tenant, platform-level access control
|
||||
|
||||
**Roles:**
|
||||
- `super_admin` - Full platform access, can perform any operation
|
||||
- `admin` - System administrator, platform management capabilities
|
||||
- `manager` - Mid-level management access
|
||||
- `user` - Basic authenticated user
|
||||
|
||||
**Use Cases:**
|
||||
- Platform administration
|
||||
- Cross-tenant operations
|
||||
- System-wide features
|
||||
- User management at platform level
|
||||
|
||||
#### 2. Tenant-Specific Roles (Tenant Service)
|
||||
|
||||
**Purpose:** Organization/tenant-level permissions
|
||||
**Service:** Tenant Service
|
||||
**Storage:** `TenantMember` model
|
||||
**Scope:** Per-tenant access control
|
||||
|
||||
**Roles:**
|
||||
- `owner` - Full control of the tenant, can transfer ownership, manage all aspects
|
||||
- `admin` - Tenant administrator, can manage team members and most operations
|
||||
- `member` - Standard team member, regular operational access
|
||||
- `viewer` - Read-only observer, view-only access to tenant data
|
||||
|
||||
**Use Cases:**
|
||||
- Team management
|
||||
- Organization-specific operations
|
||||
- Resource access within a tenant
|
||||
- Most application features
|
||||
|
||||
## Role Mapping
|
||||
|
||||
When users are created through tenant management (pilot phase), tenant roles are automatically mapped to appropriate global roles:
|
||||
|
||||
```
|
||||
Tenant Role → Global Role │ Rationale
|
||||
─────────────────────────────────────────────────
|
||||
admin → admin │ Administrative access
|
||||
member → manager │ Management-level access
|
||||
viewer → user │ Basic user access
|
||||
owner → (no mapping) │ Owner is tenant-specific only
|
||||
```
|
||||
|
||||
**Implementation:**
|
||||
- Frontend: `frontend/src/types/roles.ts`
|
||||
- Backend: `services/tenant/app/api/tenant_members.py` (lines 68-76)
|
||||
|
||||
## Permission Checking
|
||||
|
||||
### Unified Permission System
|
||||
|
||||
Location: `frontend/src/utils/permissions.ts`
|
||||
|
||||
The unified permission system provides centralized functions for checking permissions:
|
||||
|
||||
#### Functions
|
||||
|
||||
1. **`checkGlobalPermission(user, options)`**
|
||||
- Check platform-wide permissions
|
||||
- Used for: System settings, platform admin features
|
||||
|
||||
2. **`checkTenantPermission(tenantAccess, options)`**
|
||||
- Check tenant-specific permissions
|
||||
- Used for: Team management, tenant resources
|
||||
|
||||
3. **`checkCombinedPermission(user, tenantAccess, options)`**
|
||||
- Check either global OR tenant permissions
|
||||
- Used for: Mixed access scenarios
|
||||
|
||||
4. **Helper Functions:**
|
||||
- `canManageTeam()` - Check team management permission
|
||||
- `isTenantOwner()` - Check if user is tenant owner
|
||||
- `canPerformAdminActions()` - Check admin permissions
|
||||
- `getEffectivePermissions()` - Get all permission flags
|
||||
|
||||
### Usage Examples
|
||||
|
||||
```typescript
|
||||
// Check if user can manage platform users (global only)
|
||||
checkGlobalPermission(user, { requiredRole: 'admin' })
|
||||
|
||||
// Check if user can manage tenant team (tenant only)
|
||||
checkTenantPermission(tenantAccess, { requiredRole: 'owner' })
|
||||
|
||||
// Check if user can access a feature (either global admin OR tenant owner)
|
||||
checkCombinedPermission(user, tenantAccess, {
|
||||
globalRoles: ['admin', 'super_admin'],
|
||||
tenantRoles: ['owner']
|
||||
})
|
||||
```
|
||||
|
||||
## Route Protection
|
||||
|
||||
### Protected Routes
|
||||
|
||||
Location: `frontend/src/router/ProtectedRoute.tsx`
|
||||
|
||||
All protected routes now use the unified permission system:
|
||||
|
||||
```typescript
|
||||
// Admin Route: Global admin OR tenant owner/admin
|
||||
<AdminRoute>
|
||||
<Component />
|
||||
</AdminRoute>
|
||||
|
||||
// Manager Route: Global admin/manager OR tenant admin/owner/member
|
||||
<ManagerRoute>
|
||||
<Component />
|
||||
</ManagerRoute>
|
||||
|
||||
// Owner Route: Super admin OR tenant owner only
|
||||
<OwnerRoute>
|
||||
<Component />
|
||||
</OwnerRoute>
|
||||
```
|
||||
|
||||
## Team Management
|
||||
|
||||
### Core Features
|
||||
|
||||
#### 1. Add Team Members
|
||||
- **Permission Required:** Tenant Owner or Admin
|
||||
- **Options:**
|
||||
- Add existing user to tenant
|
||||
- Create new user and add to tenant (pilot phase)
|
||||
- **Subscription Limits:** Checked before adding members
|
||||
|
||||
#### 2. Update Member Roles
|
||||
- **Permission Required:** Context-dependent
|
||||
- Viewer → Member: Any admin
|
||||
- Member → Admin: Owner only
|
||||
- Admin → Member: Owner only
|
||||
- **Restrictions:** Cannot change Owner role via standard UI
|
||||
|
||||
#### 3. Remove Members
|
||||
- **Permission Required:** Owner only
|
||||
- **Restrictions:** Cannot remove the Owner
|
||||
|
||||
#### 4. Transfer Ownership
|
||||
- **Permission Required:** Owner only
|
||||
- **Requirements:**
|
||||
- New owner must be an existing Admin
|
||||
- Two-step confirmation process
|
||||
- Irreversible operation
|
||||
- **Changes:**
|
||||
- New user becomes Owner
|
||||
- Previous owner becomes Admin
|
||||
|
||||
### Team Page
|
||||
|
||||
Location: `frontend/src/pages/app/settings/team/TeamPage.tsx`
|
||||
|
||||
**Features:**
|
||||
- Team member list with role indicators
|
||||
- Filter by role
|
||||
- Search by name/email
|
||||
- Member details modal
|
||||
- Activity tracking
|
||||
- Transfer ownership modal
|
||||
- Error recovery for missing user data
|
||||
|
||||
**Security:**
|
||||
- Removed insecure owner_id fallback
|
||||
- Proper access validation through backend
|
||||
- Permission-based UI rendering
|
||||
|
||||
## Backend Implementation
|
||||
|
||||
### Tenant Member Endpoints
|
||||
|
||||
Location: `services/tenant/app/api/tenant_members.py`
|
||||
|
||||
**Endpoints:**
|
||||
1. `POST /tenants/{tenant_id}/members/with-user` - Add member with optional user creation
|
||||
2. `POST /tenants/{tenant_id}/members` - Add existing user
|
||||
3. `GET /tenants/{tenant_id}/members` - List members
|
||||
4. `PUT /tenants/{tenant_id}/members/{user_id}/role` - Update role
|
||||
5. `DELETE /tenants/{tenant_id}/members/{user_id}` - Remove member
|
||||
6. `POST /tenants/{tenant_id}/transfer-ownership` - Transfer ownership
|
||||
7. `GET /tenants/{tenant_id}/admins` - Get tenant admins
|
||||
8. `DELETE /tenants/user/{user_id}/memberships` - Delete user memberships (internal)
|
||||
|
||||
### Member Enrichment
|
||||
|
||||
The backend enriches tenant members with user data from the Auth service:
|
||||
- User full name
|
||||
- Email
|
||||
- Phone
|
||||
- Last login
|
||||
- Language/timezone preferences
|
||||
|
||||
**Error Handling:**
|
||||
- Graceful degradation if Auth service unavailable
|
||||
- Fallback to user_id if enrichment fails
|
||||
- Frontend displays warning for incomplete data
|
||||
|
||||
## Best Practices
|
||||
|
||||
### When to Use Which Permission Check
|
||||
|
||||
1. **Global Permission Check:**
|
||||
- Platform administration
|
||||
- Cross-tenant operations
|
||||
- System-wide features
|
||||
- User management at platform level
|
||||
|
||||
2. **Tenant Permission Check:**
|
||||
- Team management
|
||||
- Organization-specific resources
|
||||
- Tenant settings
|
||||
- Most application features
|
||||
|
||||
3. **Combined Permission Check:**
|
||||
- Features requiring elevated access
|
||||
- Admin-only operations that can be done by either global or tenant admins
|
||||
- Owner-specific operations with super_admin override
|
||||
|
||||
### Security Considerations
|
||||
|
||||
1. **Never use client-side owner_id comparison as fallback**
|
||||
- Always validate through backend
|
||||
- Use proper access endpoints
|
||||
|
||||
2. **Always validate permissions on the backend**
|
||||
- Frontend checks are for UX only
|
||||
- Backend is source of truth
|
||||
|
||||
3. **Use unified permission system**
|
||||
- Consistent permission checking
|
||||
- Clear documentation
|
||||
- Type-safe
|
||||
|
||||
4. **Audit critical operations**
|
||||
- Log role changes
|
||||
- Track ownership transfers
|
||||
- Monitor member additions/removals
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
### Planned Features
|
||||
|
||||
1. **Role Change History**
|
||||
- Audit trail for role changes
|
||||
- Display who changed roles and when
|
||||
- Integrated into member details modal
|
||||
|
||||
2. **Fine-grained Permissions**
|
||||
- Custom permission sets
|
||||
- Permission groups
|
||||
- Resource-level permissions
|
||||
|
||||
3. **Invitation Flow**
|
||||
- Replace direct user creation
|
||||
- Email-based invitations
|
||||
- Invitation expiration
|
||||
|
||||
4. **Member Status Management**
|
||||
- Activate/deactivate members
|
||||
- Suspend access temporarily
|
||||
- Bulk status updates
|
||||
|
||||
5. **Advanced Team Features**
|
||||
- Sub-teams/departments
|
||||
- Role templates
|
||||
- Bulk role assignments
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
#### "Permission Denied" Errors
|
||||
- **Cause:** User lacks required role or permission
|
||||
- **Solution:** Verify user's tenant membership and role
|
||||
- **Check:** `currentTenantAccess` in tenant store
|
||||
|
||||
#### Missing User Data in Team List
|
||||
- **Cause:** Auth service enrichment failed
|
||||
- **Solution:** Check Auth service connectivity
|
||||
- **Workaround:** Frontend displays warning and fallback data
|
||||
|
||||
#### Cannot Transfer Ownership
|
||||
- **Cause:** No eligible admins
|
||||
- **Solution:** Promote a member to admin first
|
||||
- **Requirement:** New owner must be an existing admin
|
||||
|
||||
#### Access Validation Stuck Loading
|
||||
- **Cause:** Tenant access endpoint not responding
|
||||
- **Solution:** Reload page or check backend logs
|
||||
- **Prevention:** Backend health monitoring
|
||||
|
||||
## API Reference
|
||||
|
||||
### Frontend
|
||||
|
||||
**Permission Functions:** `frontend/src/utils/permissions.ts`
|
||||
**Protected Routes:** `frontend/src/router/ProtectedRoute.tsx`
|
||||
**Role Types:** `frontend/src/types/roles.ts`
|
||||
**Team Management:** `frontend/src/pages/app/settings/team/TeamPage.tsx`
|
||||
**Transfer Modal:** `frontend/src/components/domain/team/TransferOwnershipModal.tsx`
|
||||
|
||||
### Backend
|
||||
|
||||
**Tenant Members API:** `services/tenant/app/api/tenant_members.py`
|
||||
**Tenant Models:** `services/tenant/app/models/tenants.py`
|
||||
**Tenant Service:** `services/tenant/app/services/tenant_service.py`
|
||||
|
||||
## Migration Notes
|
||||
|
||||
### From Single Role System
|
||||
|
||||
If migrating from a single role system:
|
||||
|
||||
1. **Audit existing roles**
|
||||
- Map old roles to new structure
|
||||
- Identify tenant vs global roles
|
||||
|
||||
2. **Update permission checks**
|
||||
- Replace old checks with unified system
|
||||
- Test all protected routes
|
||||
|
||||
3. **Migrate user data**
|
||||
- Set appropriate global roles
|
||||
- Create tenant memberships
|
||||
- Ensure owners are properly set
|
||||
|
||||
4. **Update frontend components**
|
||||
- Use new permission functions
|
||||
- Update route guards
|
||||
- Test all scenarios
|
||||
|
||||
## Support
|
||||
|
||||
For issues or questions about the roles and permissions system:
|
||||
|
||||
1. **Check this documentation**
|
||||
2. **Review code comments** in permission utilities
|
||||
3. **Check backend logs** for permission errors
|
||||
4. **Verify tenant membership** in database
|
||||
5. **Test with different user roles** to isolate issues
|
||||
|
||||
---
|
||||
|
||||
**Last Updated:** 2025-10-31
|
||||
**Version:** 1.0.0
|
||||
**Status:** ✅ Production Ready
|
||||
@@ -18,22 +18,6 @@ import {
|
||||
GetCustomersParams,
|
||||
UpdateOrderStatusParams,
|
||||
GetDemandRequirementsParams,
|
||||
// Procurement types
|
||||
ProcurementPlanResponse,
|
||||
ProcurementPlanCreate,
|
||||
ProcurementPlanUpdate,
|
||||
ProcurementRequirementResponse,
|
||||
ProcurementRequirementUpdate,
|
||||
ProcurementDashboardData,
|
||||
GeneratePlanRequest,
|
||||
GeneratePlanResponse,
|
||||
PaginatedProcurementPlans,
|
||||
GetProcurementPlansParams,
|
||||
CreatePOsResult,
|
||||
LinkRequirementToPORequest,
|
||||
UpdateDeliveryStatusRequest,
|
||||
GetPlanRequirementsParams,
|
||||
UpdatePlanStatusParams,
|
||||
} from '../types/orders';
|
||||
import { ApiError } from '../client/apiClient';
|
||||
|
||||
@@ -58,17 +42,6 @@ export const ordersKeys = {
|
||||
|
||||
// Status
|
||||
status: (tenantId: string) => [...ordersKeys.all, 'status', tenantId] as const,
|
||||
|
||||
// Procurement
|
||||
procurement: () => [...ordersKeys.all, 'procurement'] as const,
|
||||
procurementPlans: (params: GetProcurementPlansParams) => [...ordersKeys.procurement(), 'plans', params] as const,
|
||||
procurementPlan: (tenantId: string, planId: string) => [...ordersKeys.procurement(), 'plan', tenantId, planId] as const,
|
||||
procurementPlanByDate: (tenantId: string, date: string) => [...ordersKeys.procurement(), 'plan-by-date', tenantId, date] as const,
|
||||
currentProcurementPlan: (tenantId: string) => [...ordersKeys.procurement(), 'current-plan', tenantId] as const,
|
||||
procurementDashboard: (tenantId: string) => [...ordersKeys.procurement(), 'dashboard', tenantId] as const,
|
||||
planRequirements: (params: GetPlanRequirementsParams) => [...ordersKeys.procurement(), 'requirements', params] as const,
|
||||
criticalRequirements: (tenantId: string) => [...ordersKeys.procurement(), 'critical-requirements', tenantId] as const,
|
||||
procurementHealth: (tenantId: string) => [...ordersKeys.procurement(), 'health', tenantId] as const,
|
||||
} as const;
|
||||
|
||||
// ===== Order Queries =====
|
||||
@@ -360,378 +333,3 @@ export const useInvalidateOrders = () => {
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
// ===== Procurement Queries =====
|
||||
|
||||
export const useProcurementPlans = (
|
||||
params: GetProcurementPlansParams,
|
||||
options?: Omit<UseQueryOptions<PaginatedProcurementPlans, ApiError>, 'queryKey' | 'queryFn'>
|
||||
) => {
|
||||
return useQuery<PaginatedProcurementPlans, ApiError>({
|
||||
queryKey: ordersKeys.procurementPlans(params),
|
||||
queryFn: () => OrdersService.getProcurementPlans(params),
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
enabled: !!params.tenant_id,
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
export const useProcurementPlan = (
|
||||
tenantId: string,
|
||||
planId: string,
|
||||
options?: Omit<UseQueryOptions<ProcurementPlanResponse | null, ApiError>, 'queryKey' | 'queryFn'>
|
||||
) => {
|
||||
return useQuery<ProcurementPlanResponse | null, ApiError>({
|
||||
queryKey: ordersKeys.procurementPlan(tenantId, planId),
|
||||
queryFn: () => OrdersService.getProcurementPlanById(tenantId, planId),
|
||||
staleTime: 2 * 60 * 1000, // 2 minutes
|
||||
enabled: !!tenantId && !!planId,
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
export const useProcurementPlanByDate = (
|
||||
tenantId: string,
|
||||
planDate: string,
|
||||
options?: Omit<UseQueryOptions<ProcurementPlanResponse | null, ApiError>, 'queryKey' | 'queryFn'>
|
||||
) => {
|
||||
return useQuery<ProcurementPlanResponse | null, ApiError>({
|
||||
queryKey: ordersKeys.procurementPlanByDate(tenantId, planDate),
|
||||
queryFn: () => OrdersService.getProcurementPlanByDate(tenantId, planDate),
|
||||
staleTime: 2 * 60 * 1000, // 2 minutes
|
||||
enabled: !!tenantId && !!planDate,
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
export const useCurrentProcurementPlan = (
|
||||
tenantId: string,
|
||||
options?: Omit<UseQueryOptions<ProcurementPlanResponse | null, ApiError>, 'queryKey' | 'queryFn'>
|
||||
) => {
|
||||
return useQuery<ProcurementPlanResponse | null, ApiError>({
|
||||
queryKey: ordersKeys.currentProcurementPlan(tenantId),
|
||||
queryFn: () => OrdersService.getCurrentProcurementPlan(tenantId),
|
||||
staleTime: 1 * 60 * 1000, // 1 minute
|
||||
enabled: !!tenantId,
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
export const useProcurementDashboard = (
|
||||
tenantId: string,
|
||||
options?: Omit<UseQueryOptions<ProcurementDashboardData | null, ApiError>, 'queryKey' | 'queryFn'>
|
||||
) => {
|
||||
return useQuery<ProcurementDashboardData | null, ApiError>({
|
||||
queryKey: ordersKeys.procurementDashboard(tenantId),
|
||||
queryFn: () => OrdersService.getProcurementDashboard(tenantId),
|
||||
staleTime: 2 * 60 * 1000, // 2 minutes
|
||||
enabled: !!tenantId,
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
export const usePlanRequirements = (
|
||||
params: GetPlanRequirementsParams,
|
||||
options?: Omit<UseQueryOptions<ProcurementRequirementResponse[], ApiError>, 'queryKey' | 'queryFn'>
|
||||
) => {
|
||||
return useQuery<ProcurementRequirementResponse[], ApiError>({
|
||||
queryKey: ordersKeys.planRequirements(params),
|
||||
queryFn: () => OrdersService.getPlanRequirements(params),
|
||||
staleTime: 2 * 60 * 1000, // 2 minutes
|
||||
enabled: !!params.tenant_id && !!params.plan_id,
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
export const useCriticalRequirements = (
|
||||
tenantId: string,
|
||||
options?: Omit<UseQueryOptions<ProcurementRequirementResponse[], ApiError>, 'queryKey' | 'queryFn'>
|
||||
) => {
|
||||
return useQuery<ProcurementRequirementResponse[], ApiError>({
|
||||
queryKey: ordersKeys.criticalRequirements(tenantId),
|
||||
queryFn: () => OrdersService.getCriticalRequirements(tenantId),
|
||||
staleTime: 1 * 60 * 1000, // 1 minute
|
||||
enabled: !!tenantId,
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
export const useProcurementHealth = (
|
||||
tenantId: string,
|
||||
options?: Omit<UseQueryOptions<{ status: string; service: string; procurement_enabled: boolean; timestamp: string }, ApiError>, 'queryKey' | 'queryFn'>
|
||||
) => {
|
||||
return useQuery<{ status: string; service: string; procurement_enabled: boolean; timestamp: string }, ApiError>({
|
||||
queryKey: ordersKeys.procurementHealth(tenantId),
|
||||
queryFn: () => OrdersService.getProcurementHealth(tenantId),
|
||||
staleTime: 30 * 1000, // 30 seconds
|
||||
enabled: !!tenantId,
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
// ===== Procurement Mutations =====
|
||||
|
||||
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 }) => OrdersService.generateProcurementPlan(tenantId, request),
|
||||
onSuccess: (data, variables) => {
|
||||
// Invalidate all procurement queries for this tenant
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ordersKeys.procurement(),
|
||||
predicate: (query) => {
|
||||
return JSON.stringify(query.queryKey).includes(variables.tenantId);
|
||||
},
|
||||
});
|
||||
|
||||
// If plan was generated successfully, cache it
|
||||
if (data.success && data.plan) {
|
||||
queryClient.setQueryData(
|
||||
ordersKeys.procurementPlan(variables.tenantId, data.plan.id),
|
||||
data.plan
|
||||
);
|
||||
}
|
||||
},
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
export const useUpdateProcurementPlanStatus = (
|
||||
options?: UseMutationOptions<ProcurementPlanResponse, ApiError, UpdatePlanStatusParams>
|
||||
) => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<ProcurementPlanResponse, ApiError, UpdatePlanStatusParams>({
|
||||
mutationFn: (params) => OrdersService.updateProcurementPlanStatus(params),
|
||||
onSuccess: (data, variables) => {
|
||||
// Update the specific plan in cache
|
||||
queryClient.setQueryData(
|
||||
ordersKeys.procurementPlan(variables.tenant_id, variables.plan_id),
|
||||
data
|
||||
);
|
||||
|
||||
// Invalidate plans list
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ordersKeys.procurement(),
|
||||
predicate: (query) => {
|
||||
const queryKey = query.queryKey as string[];
|
||||
return queryKey.includes('plans') &&
|
||||
JSON.stringify(queryKey).includes(variables.tenant_id);
|
||||
},
|
||||
});
|
||||
|
||||
// Invalidate dashboard
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ordersKeys.procurementDashboard(variables.tenant_id),
|
||||
});
|
||||
},
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
export const useTriggerDailyScheduler = (
|
||||
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) => OrdersService.triggerDailyScheduler(tenantId),
|
||||
onSuccess: (data, tenantId) => {
|
||||
// Invalidate all procurement data for this tenant
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ordersKeys.procurement(),
|
||||
predicate: (query) => {
|
||||
return JSON.stringify(query.queryKey).includes(tenantId);
|
||||
},
|
||||
});
|
||||
},
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
// ===== NEW PROCUREMENT FEATURE HOOKS =====
|
||||
|
||||
/**
|
||||
* Hook to recalculate a 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 }) => OrdersService.recalculateProcurementPlan(tenantId, planId),
|
||||
onSuccess: (data, variables) => {
|
||||
if (data.plan) {
|
||||
// Update the specific plan in cache
|
||||
queryClient.setQueryData(
|
||||
ordersKeys.procurementPlan(variables.tenantId, variables.planId),
|
||||
data.plan
|
||||
);
|
||||
}
|
||||
|
||||
// Invalidate plans list and dashboard
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ordersKeys.procurement(),
|
||||
predicate: (query) => {
|
||||
return JSON.stringify(query.queryKey).includes(variables.tenantId);
|
||||
},
|
||||
});
|
||||
},
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to 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 }) =>
|
||||
OrdersService.approveProcurementPlan(tenantId, planId, { approval_notes }),
|
||||
onSuccess: (data, variables) => {
|
||||
// Update the specific plan in cache
|
||||
queryClient.setQueryData(
|
||||
ordersKeys.procurementPlan(variables.tenantId, variables.planId),
|
||||
data
|
||||
);
|
||||
|
||||
// Invalidate plans list and dashboard
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ordersKeys.procurement(),
|
||||
predicate: (query) => {
|
||||
return JSON.stringify(query.queryKey).includes(variables.tenantId);
|
||||
},
|
||||
});
|
||||
},
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to 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 }) =>
|
||||
OrdersService.rejectProcurementPlan(tenantId, planId, { rejection_notes }),
|
||||
onSuccess: (data, variables) => {
|
||||
// Update the specific plan in cache
|
||||
queryClient.setQueryData(
|
||||
ordersKeys.procurementPlan(variables.tenantId, variables.planId),
|
||||
data
|
||||
);
|
||||
|
||||
// Invalidate plans list and dashboard
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ordersKeys.procurement(),
|
||||
predicate: (query) => {
|
||||
return JSON.stringify(query.queryKey).includes(variables.tenantId);
|
||||
},
|
||||
});
|
||||
},
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to 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 }) =>
|
||||
OrdersService.createPurchaseOrdersFromPlan(tenantId, planId, autoApprove),
|
||||
onSuccess: (data, variables) => {
|
||||
// Invalidate procurement plan to refresh requirements status
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ordersKeys.procurementPlan(variables.tenantId, variables.planId),
|
||||
});
|
||||
|
||||
// Invalidate dashboard
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ordersKeys.procurementDashboard(variables.tenantId),
|
||||
});
|
||||
},
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to link a 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 }) =>
|
||||
OrdersService.linkRequirementToPurchaseOrder(tenantId, requirementId, request),
|
||||
onSuccess: (data, variables) => {
|
||||
// Invalidate procurement data to refresh requirements
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ordersKeys.procurement(),
|
||||
predicate: (query) => {
|
||||
return JSON.stringify(query.queryKey).includes(variables.tenantId);
|
||||
},
|
||||
});
|
||||
},
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to 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 }) =>
|
||||
OrdersService.updateRequirementDeliveryStatus(tenantId, requirementId, request),
|
||||
onSuccess: (data, variables) => {
|
||||
// Invalidate procurement data to refresh requirements
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ordersKeys.procurement(),
|
||||
predicate: (query) => {
|
||||
return JSON.stringify(query.queryKey).includes(variables.tenantId);
|
||||
},
|
||||
});
|
||||
},
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
926
frontend/src/api/hooks/performance.ts
Normal file
926
frontend/src/api/hooks/performance.ts
Normal file
@@ -0,0 +1,926 @@
|
||||
/**
|
||||
* Performance Analytics Hooks
|
||||
* React Query hooks for fetching real-time performance data across all departments
|
||||
*/
|
||||
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useMemo } from 'react';
|
||||
import {
|
||||
PerformanceOverview,
|
||||
DepartmentPerformance,
|
||||
KPIMetric,
|
||||
PerformanceAlert,
|
||||
HourlyProductivity,
|
||||
ProductionPerformance,
|
||||
InventoryPerformance,
|
||||
SalesPerformance,
|
||||
ProcurementPerformance,
|
||||
TimePeriod,
|
||||
} from '../types/performance';
|
||||
import { useProductionDashboard } from './production';
|
||||
import { useInventoryDashboard } from './dashboard';
|
||||
import { useSalesAnalytics } from './sales';
|
||||
import { useProcurementDashboard } from './procurement';
|
||||
import { useOrdersDashboard } from './orders';
|
||||
|
||||
// ============================================================================
|
||||
// Helper Functions
|
||||
// ============================================================================
|
||||
|
||||
const getDateRangeForPeriod = (period: TimePeriod): { startDate: string; endDate: string } => {
|
||||
const endDate = new Date();
|
||||
const startDate = new Date();
|
||||
|
||||
switch (period) {
|
||||
case 'day':
|
||||
startDate.setDate(endDate.getDate() - 1);
|
||||
break;
|
||||
case 'week':
|
||||
startDate.setDate(endDate.getDate() - 7);
|
||||
break;
|
||||
case 'month':
|
||||
startDate.setMonth(endDate.getMonth() - 1);
|
||||
break;
|
||||
case 'quarter':
|
||||
startDate.setMonth(endDate.getMonth() - 3);
|
||||
break;
|
||||
case 'year':
|
||||
startDate.setFullYear(endDate.getFullYear() - 1);
|
||||
break;
|
||||
}
|
||||
|
||||
return {
|
||||
startDate: startDate.toISOString().split('T')[0],
|
||||
endDate: endDate.toISOString().split('T')[0],
|
||||
};
|
||||
};
|
||||
|
||||
const calculateTrend = (current: number, previous: number): 'up' | 'down' | 'stable' => {
|
||||
const change = ((current - previous) / previous) * 100;
|
||||
if (Math.abs(change) < 1) return 'stable';
|
||||
return change > 0 ? 'up' : 'down';
|
||||
};
|
||||
|
||||
const calculateStatus = (current: number, target: number): 'good' | 'warning' | 'critical' => {
|
||||
const percentage = (current / target) * 100;
|
||||
if (percentage >= 95) return 'good';
|
||||
if (percentage >= 85) return 'warning';
|
||||
return 'critical';
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Production Performance Hook
|
||||
// ============================================================================
|
||||
|
||||
export const useProductionPerformance = (tenantId: string, period: TimePeriod = 'week') => {
|
||||
const { data: dashboard, isLoading: dashboardLoading } = useProductionDashboard(tenantId);
|
||||
|
||||
// Extract primitive values to prevent unnecessary recalculations
|
||||
const efficiencyPercentage = dashboard?.efficiency_percentage || 0;
|
||||
const qualityScore = dashboard?.average_quality_score || 0;
|
||||
const capacityUtilization = dashboard?.capacity_utilization || 0;
|
||||
const onTimeCompletionRate = dashboard?.on_time_completion_rate || 0;
|
||||
|
||||
const performance: ProductionPerformance | undefined = useMemo(() => {
|
||||
if (!dashboard) return undefined;
|
||||
|
||||
return {
|
||||
efficiency: efficiencyPercentage,
|
||||
average_batch_time: 0, // Not available in dashboard
|
||||
quality_rate: qualityScore,
|
||||
waste_percentage: 0, // Would need production-trends endpoint
|
||||
capacity_utilization: capacityUtilization,
|
||||
equipment_efficiency: capacityUtilization,
|
||||
on_time_completion_rate: onTimeCompletionRate,
|
||||
yield_rate: 0, // Would need production-trends endpoint
|
||||
};
|
||||
}, [efficiencyPercentage, qualityScore, capacityUtilization, onTimeCompletionRate]);
|
||||
|
||||
return {
|
||||
data: performance,
|
||||
isLoading: dashboardLoading,
|
||||
};
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Inventory Performance Hook
|
||||
// ============================================================================
|
||||
|
||||
export const useInventoryPerformance = (tenantId: string) => {
|
||||
const { data: dashboard, isLoading: dashboardLoading } = useInventoryDashboard(tenantId);
|
||||
|
||||
// Extract primitive values to prevent unnecessary recalculations
|
||||
const totalItems = dashboard?.total_ingredients || 1;
|
||||
const lowStockCount = dashboard?.low_stock_items || 0;
|
||||
const outOfStockCount = dashboard?.out_of_stock_items || 0;
|
||||
const foodSafetyAlertsActive = dashboard?.food_safety_alerts_active || 0;
|
||||
const expiringItems = dashboard?.expiring_soon_items || 0;
|
||||
const stockValue = dashboard?.total_stock_value || 0;
|
||||
|
||||
const performance: InventoryPerformance | undefined = useMemo(() => {
|
||||
if (!dashboard) return undefined;
|
||||
|
||||
return {
|
||||
stock_accuracy: 100 - ((lowStockCount + outOfStockCount) / totalItems * 100),
|
||||
turnover_rate: 0, // TODO: Not available in dashboard
|
||||
waste_rate: 0, // TODO: Derive from stock movements if available
|
||||
low_stock_count: lowStockCount,
|
||||
compliance_rate: foodSafetyAlertsActive === 0 ? 100 : 90, // Simplified compliance
|
||||
expiring_items_count: expiringItems,
|
||||
stock_value: stockValue,
|
||||
};
|
||||
}, [totalItems, lowStockCount, outOfStockCount, foodSafetyAlertsActive, expiringItems, stockValue]);
|
||||
|
||||
return {
|
||||
data: performance,
|
||||
isLoading: dashboardLoading,
|
||||
};
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Sales Performance Hook
|
||||
// ============================================================================
|
||||
|
||||
export const useSalesPerformance = (tenantId: string, period: TimePeriod = 'week') => {
|
||||
const { startDate, endDate } = getDateRangeForPeriod(period);
|
||||
|
||||
const { data: salesData, isLoading: salesLoading } = useSalesAnalytics(
|
||||
tenantId,
|
||||
startDate,
|
||||
endDate
|
||||
);
|
||||
|
||||
// Extract primitive values to prevent unnecessary recalculations
|
||||
const totalRevenue = salesData?.total_revenue || 0;
|
||||
const totalTransactions = salesData?.total_transactions || 0;
|
||||
const avgTransactionValue = salesData?.average_transaction_value || 0;
|
||||
const topProductsString = salesData?.top_products ? JSON.stringify(salesData.top_products) : '[]';
|
||||
|
||||
const performance: SalesPerformance | undefined = useMemo(() => {
|
||||
if (!salesData) return undefined;
|
||||
|
||||
const topProducts = JSON.parse(topProductsString);
|
||||
|
||||
return {
|
||||
total_revenue: totalRevenue,
|
||||
total_transactions: totalTransactions,
|
||||
average_transaction_value: avgTransactionValue,
|
||||
growth_rate: 0, // TODO: Calculate from trends
|
||||
channel_performance: [], // TODO: Parse from sales_by_channel if needed
|
||||
top_products: Array.isArray(topProducts)
|
||||
? topProducts.map((product: any) => ({
|
||||
product_id: product.inventory_product_id || '',
|
||||
product_name: product.product_name || '',
|
||||
sales: product.total_quantity || 0,
|
||||
revenue: product.total_revenue || 0,
|
||||
}))
|
||||
: [],
|
||||
};
|
||||
}, [totalRevenue, totalTransactions, avgTransactionValue, topProductsString]);
|
||||
|
||||
return {
|
||||
data: performance,
|
||||
isLoading: salesLoading,
|
||||
};
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Procurement Performance Hook
|
||||
// ============================================================================
|
||||
|
||||
export const useProcurementPerformance = (tenantId: string) => {
|
||||
const { data: dashboard, isLoading } = useProcurementDashboard(tenantId);
|
||||
|
||||
// Extract primitive values to prevent unnecessary recalculations
|
||||
const avgFulfillmentRate = dashboard?.performance_metrics?.average_fulfillment_rate || 0;
|
||||
const avgOnTimeDelivery = dashboard?.performance_metrics?.average_on_time_delivery || 0;
|
||||
const costAccuracy = dashboard?.performance_metrics?.cost_accuracy || 0;
|
||||
const supplierPerformance = dashboard?.performance_metrics?.supplier_performance || 0;
|
||||
const totalPlans = dashboard?.summary?.total_plans || 0;
|
||||
const lowStockCount = dashboard?.low_stock_alerts?.length || 0;
|
||||
const overdueCount = dashboard?.overdue_requirements?.length || 0;
|
||||
|
||||
const performance: ProcurementPerformance | undefined = useMemo(() => {
|
||||
if (!dashboard) return undefined;
|
||||
|
||||
return {
|
||||
fulfillment_rate: avgFulfillmentRate,
|
||||
on_time_delivery_rate: avgOnTimeDelivery,
|
||||
cost_accuracy: costAccuracy,
|
||||
supplier_performance_score: supplierPerformance,
|
||||
active_plans: totalPlans,
|
||||
critical_requirements: lowStockCount + overdueCount,
|
||||
};
|
||||
}, [avgFulfillmentRate, avgOnTimeDelivery, costAccuracy, supplierPerformance, totalPlans, lowStockCount, overdueCount]);
|
||||
|
||||
return {
|
||||
data: performance,
|
||||
isLoading,
|
||||
};
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Performance Overview Hook (Aggregates All Departments)
|
||||
// ============================================================================
|
||||
|
||||
export const usePerformanceOverview = (tenantId: string, period: TimePeriod = 'week') => {
|
||||
const { data: production, isLoading: productionLoading } = useProductionPerformance(tenantId, period);
|
||||
const { data: inventory, isLoading: inventoryLoading } = useInventoryPerformance(tenantId);
|
||||
const { data: sales, isLoading: salesLoading } = useSalesPerformance(tenantId, period);
|
||||
const { data: procurement, isLoading: procurementLoading } = useProcurementPerformance(tenantId);
|
||||
const { data: orders, isLoading: ordersLoading } = useOrdersDashboard(tenantId);
|
||||
|
||||
const overview: PerformanceOverview | undefined = useMemo(() => {
|
||||
if (!production || !inventory || !sales || !procurement || !orders) return undefined;
|
||||
|
||||
// Calculate customer satisfaction from order fulfillment and delivery performance
|
||||
const totalOrders = orders.total_orders_today || 1;
|
||||
const deliveredOrders = orders.delivered_orders || 0;
|
||||
const orderFulfillmentRate = (deliveredOrders / totalOrders) * 100;
|
||||
|
||||
const customerSatisfaction = (orderFulfillmentRate + procurement.on_time_delivery_rate) / 2;
|
||||
|
||||
return {
|
||||
overall_efficiency: production.efficiency,
|
||||
average_production_time: production.average_batch_time,
|
||||
quality_score: production.quality_rate,
|
||||
employee_productivity: production.capacity_utilization,
|
||||
customer_satisfaction: customerSatisfaction,
|
||||
resource_utilization: production.equipment_efficiency || production.capacity_utilization,
|
||||
};
|
||||
}, [production, inventory, sales, procurement, orders]);
|
||||
|
||||
return {
|
||||
data: overview,
|
||||
isLoading: productionLoading || inventoryLoading || salesLoading || procurementLoading || ordersLoading,
|
||||
};
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Department Performance Hook
|
||||
// ============================================================================
|
||||
|
||||
export const useDepartmentPerformance = (tenantId: string, period: TimePeriod = 'week') => {
|
||||
const { data: production, isLoading: productionLoading } = useProductionPerformance(tenantId, period);
|
||||
const { data: inventory, isLoading: inventoryLoading } = useInventoryPerformance(tenantId);
|
||||
const { data: sales, isLoading: salesLoading } = useSalesPerformance(tenantId, period);
|
||||
const { data: procurement, isLoading: procurementLoading } = useProcurementPerformance(tenantId);
|
||||
|
||||
// Extract primitive values before useMemo to prevent unnecessary recalculations
|
||||
const productionEfficiency = production?.efficiency || 0;
|
||||
const productionAvgBatchTime = production?.average_batch_time || 0;
|
||||
const productionQualityRate = production?.quality_rate || 0;
|
||||
const productionWastePercentage = production?.waste_percentage || 0;
|
||||
const salesTotalRevenue = sales?.total_revenue || 0;
|
||||
const salesGrowthRate = sales?.growth_rate || 0;
|
||||
const salesTotalTransactions = sales?.total_transactions || 0;
|
||||
const salesAvgTransactionValue = sales?.average_transaction_value || 0;
|
||||
const inventoryStockAccuracy = inventory?.stock_accuracy || 0;
|
||||
const inventoryLowStockCount = inventory?.low_stock_count || 0;
|
||||
const inventoryTurnoverRate = inventory?.turnover_rate || 0;
|
||||
const procurementFulfillmentRate = procurement?.fulfillment_rate || 0;
|
||||
const procurementOnTimeDeliveryRate = procurement?.on_time_delivery_rate || 0;
|
||||
const procurementCostAccuracy = procurement?.cost_accuracy || 0;
|
||||
|
||||
const departments: DepartmentPerformance[] | undefined = useMemo(() => {
|
||||
if (!production || !inventory || !sales || !procurement) return undefined;
|
||||
|
||||
return [
|
||||
{
|
||||
department_id: 'production',
|
||||
department_name: 'Producción',
|
||||
efficiency: productionEfficiency,
|
||||
trend: productionEfficiency >= 85 ? 'up' : productionEfficiency >= 75 ? 'stable' : 'down',
|
||||
metrics: {
|
||||
primary_metric: {
|
||||
label: 'Tiempo promedio de lote',
|
||||
value: productionAvgBatchTime,
|
||||
unit: 'h',
|
||||
},
|
||||
secondary_metric: {
|
||||
label: 'Tasa de calidad',
|
||||
value: productionQualityRate,
|
||||
unit: '%',
|
||||
},
|
||||
tertiary_metric: {
|
||||
label: 'Desperdicio',
|
||||
value: productionWastePercentage,
|
||||
unit: '%',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
department_id: 'sales',
|
||||
department_name: 'Ventas',
|
||||
efficiency: (salesTotalRevenue / 10000) * 100, // Normalize to percentage
|
||||
trend: salesGrowthRate > 0 ? 'up' : salesGrowthRate < 0 ? 'down' : 'stable',
|
||||
metrics: {
|
||||
primary_metric: {
|
||||
label: 'Ingresos totales',
|
||||
value: salesTotalRevenue,
|
||||
unit: '€',
|
||||
},
|
||||
secondary_metric: {
|
||||
label: 'Transacciones',
|
||||
value: salesTotalTransactions,
|
||||
unit: '',
|
||||
},
|
||||
tertiary_metric: {
|
||||
label: 'Valor promedio',
|
||||
value: salesAvgTransactionValue,
|
||||
unit: '€',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
department_id: 'inventory',
|
||||
department_name: 'Inventario',
|
||||
efficiency: inventoryStockAccuracy,
|
||||
trend: inventoryLowStockCount < 5 ? 'up' : inventoryLowStockCount < 10 ? 'stable' : 'down',
|
||||
metrics: {
|
||||
primary_metric: {
|
||||
label: 'Rotación de stock',
|
||||
value: inventoryTurnoverRate,
|
||||
unit: 'x',
|
||||
},
|
||||
secondary_metric: {
|
||||
label: 'Precisión',
|
||||
value: inventoryStockAccuracy,
|
||||
unit: '%',
|
||||
},
|
||||
tertiary_metric: {
|
||||
label: 'Items con bajo stock',
|
||||
value: inventoryLowStockCount,
|
||||
unit: '',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
department_id: 'administration',
|
||||
department_name: 'Administración',
|
||||
efficiency: procurementFulfillmentRate,
|
||||
trend: procurementOnTimeDeliveryRate >= 95 ? 'up' : procurementOnTimeDeliveryRate >= 85 ? 'stable' : 'down',
|
||||
metrics: {
|
||||
primary_metric: {
|
||||
label: 'Tasa de cumplimiento',
|
||||
value: procurementFulfillmentRate,
|
||||
unit: '%',
|
||||
},
|
||||
secondary_metric: {
|
||||
label: 'Entrega a tiempo',
|
||||
value: procurementOnTimeDeliveryRate,
|
||||
unit: '%',
|
||||
},
|
||||
tertiary_metric: {
|
||||
label: 'Precisión de costos',
|
||||
value: procurementCostAccuracy,
|
||||
unit: '%',
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
}, [
|
||||
productionEfficiency,
|
||||
productionAvgBatchTime,
|
||||
productionQualityRate,
|
||||
productionWastePercentage,
|
||||
salesTotalRevenue,
|
||||
salesGrowthRate,
|
||||
salesTotalTransactions,
|
||||
salesAvgTransactionValue,
|
||||
inventoryStockAccuracy,
|
||||
inventoryLowStockCount,
|
||||
inventoryTurnoverRate,
|
||||
procurementFulfillmentRate,
|
||||
procurementOnTimeDeliveryRate,
|
||||
procurementCostAccuracy,
|
||||
]);
|
||||
|
||||
return {
|
||||
data: departments,
|
||||
isLoading: productionLoading || inventoryLoading || salesLoading || procurementLoading,
|
||||
};
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// KPI Metrics Hook
|
||||
// ============================================================================
|
||||
|
||||
export const useKPIMetrics = (tenantId: string, period: TimePeriod = 'week') => {
|
||||
const { data: production, isLoading: productionLoading } = useProductionPerformance(tenantId, period);
|
||||
const { data: inventory, isLoading: inventoryLoading } = useInventoryPerformance(tenantId);
|
||||
const { data: procurement, isLoading: procurementLoading } = useProcurementPerformance(tenantId);
|
||||
|
||||
const kpis: KPIMetric[] | undefined = useMemo(() => {
|
||||
if (!production || !inventory || !procurement) return undefined;
|
||||
|
||||
// TODO: Get previous period data for accurate trends
|
||||
const previousProduction = production.efficiency * 0.95; // Mock previous value
|
||||
const previousInventory = inventory.stock_accuracy * 0.98;
|
||||
const previousProcurement = procurement.on_time_delivery_rate * 0.97;
|
||||
const previousQuality = production.quality_rate * 0.96;
|
||||
|
||||
return [
|
||||
{
|
||||
id: 'overall-efficiency',
|
||||
name: 'Eficiencia General',
|
||||
current_value: production.efficiency,
|
||||
target_value: 90,
|
||||
previous_value: previousProduction,
|
||||
unit: '%',
|
||||
trend: calculateTrend(production.efficiency, previousProduction),
|
||||
status: calculateStatus(production.efficiency, 90),
|
||||
},
|
||||
{
|
||||
id: 'quality-rate',
|
||||
name: 'Tasa de Calidad',
|
||||
current_value: production.quality_rate,
|
||||
target_value: 95,
|
||||
previous_value: previousQuality,
|
||||
unit: '%',
|
||||
trend: calculateTrend(production.quality_rate, previousQuality),
|
||||
status: calculateStatus(production.quality_rate, 95),
|
||||
},
|
||||
{
|
||||
id: 'on-time-delivery',
|
||||
name: 'Entrega a Tiempo',
|
||||
current_value: procurement.on_time_delivery_rate,
|
||||
target_value: 95,
|
||||
previous_value: previousProcurement,
|
||||
unit: '%',
|
||||
trend: calculateTrend(procurement.on_time_delivery_rate, previousProcurement),
|
||||
status: calculateStatus(procurement.on_time_delivery_rate, 95),
|
||||
},
|
||||
{
|
||||
id: 'inventory-accuracy',
|
||||
name: 'Precisión de Inventario',
|
||||
current_value: inventory.stock_accuracy,
|
||||
target_value: 98,
|
||||
previous_value: previousInventory,
|
||||
unit: '%',
|
||||
trend: calculateTrend(inventory.stock_accuracy, previousInventory),
|
||||
status: calculateStatus(inventory.stock_accuracy, 98),
|
||||
},
|
||||
];
|
||||
}, [production, inventory, procurement]);
|
||||
|
||||
return {
|
||||
data: kpis,
|
||||
isLoading: productionLoading || inventoryLoading || procurementLoading,
|
||||
};
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Performance Alerts Hook
|
||||
// ============================================================================
|
||||
|
||||
export const usePerformanceAlerts = (tenantId: string) => {
|
||||
const { data: inventory, isLoading: inventoryLoading } = useInventoryDashboard(tenantId);
|
||||
const { data: procurement, isLoading: procurementLoading } = useProcurementDashboard(tenantId);
|
||||
|
||||
// Extract primitive values to prevent unnecessary recalculations
|
||||
const lowStockCount = inventory?.low_stock_items || 0;
|
||||
const outOfStockCount = inventory?.out_of_stock_items || 0;
|
||||
const foodSafetyAlerts = inventory?.food_safety_alerts_active || 0;
|
||||
const expiringCount = inventory?.expiring_soon_items || 0;
|
||||
const lowStockAlertsCount = procurement?.low_stock_alerts?.length || 0;
|
||||
const overdueReqsCount = procurement?.overdue_requirements?.length || 0;
|
||||
|
||||
const alerts: PerformanceAlert[] | undefined = useMemo(() => {
|
||||
if (!inventory || !procurement) return undefined;
|
||||
|
||||
const alertsList: PerformanceAlert[] = [];
|
||||
|
||||
// Low stock alerts
|
||||
if (lowStockCount > 0) {
|
||||
alertsList.push({
|
||||
id: `low-stock-${Date.now()}`,
|
||||
type: 'warning',
|
||||
department: 'Inventario',
|
||||
message: `${lowStockCount} ingredientes con stock bajo`,
|
||||
timestamp: new Date().toISOString(),
|
||||
metric_affected: 'Stock',
|
||||
current_value: lowStockCount,
|
||||
});
|
||||
}
|
||||
|
||||
// Out of stock alerts
|
||||
if (outOfStockCount > 0) {
|
||||
alertsList.push({
|
||||
id: `out-of-stock-${Date.now()}`,
|
||||
type: 'critical',
|
||||
department: 'Inventario',
|
||||
message: `${outOfStockCount} ingredientes sin stock`,
|
||||
timestamp: new Date().toISOString(),
|
||||
metric_affected: 'Stock',
|
||||
current_value: outOfStockCount,
|
||||
});
|
||||
}
|
||||
|
||||
// Food safety alerts
|
||||
if (foodSafetyAlerts > 0) {
|
||||
alertsList.push({
|
||||
id: `food-safety-${Date.now()}`,
|
||||
type: 'critical',
|
||||
department: 'Inventario',
|
||||
message: `${foodSafetyAlerts} alertas de seguridad alimentaria activas`,
|
||||
timestamp: new Date().toISOString(),
|
||||
metric_affected: 'Seguridad Alimentaria',
|
||||
current_value: foodSafetyAlerts,
|
||||
});
|
||||
}
|
||||
|
||||
// Expiring items alerts
|
||||
if (expiringCount > 0) {
|
||||
alertsList.push({
|
||||
id: `expiring-${Date.now()}`,
|
||||
type: 'warning',
|
||||
department: 'Inventario',
|
||||
message: `${expiringCount} ingredientes próximos a vencer`,
|
||||
timestamp: new Date().toISOString(),
|
||||
metric_affected: 'Caducidad',
|
||||
current_value: expiringCount,
|
||||
});
|
||||
}
|
||||
|
||||
// Critical procurement requirements
|
||||
const criticalCount = lowStockAlertsCount + overdueReqsCount;
|
||||
|
||||
if (criticalCount > 0) {
|
||||
alertsList.push({
|
||||
id: `procurement-critical-${Date.now()}`,
|
||||
type: 'warning',
|
||||
department: 'Administración',
|
||||
message: `${criticalCount} requisitos de compra críticos`,
|
||||
timestamp: new Date().toISOString(),
|
||||
metric_affected: 'Aprovisionamiento',
|
||||
current_value: criticalCount,
|
||||
});
|
||||
}
|
||||
|
||||
// Sort by severity: critical > warning > info
|
||||
return alertsList.sort((a, b) => {
|
||||
const severityOrder = { critical: 0, warning: 1, info: 2 };
|
||||
return severityOrder[a.type] - severityOrder[b.type];
|
||||
});
|
||||
}, [lowStockCount, outOfStockCount, foodSafetyAlerts, expiringCount, lowStockAlertsCount, overdueReqsCount]);
|
||||
|
||||
return {
|
||||
data: alerts || [],
|
||||
isLoading: inventoryLoading || procurementLoading,
|
||||
};
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Hourly Productivity Hook
|
||||
// ============================================================================
|
||||
|
||||
export const useHourlyProductivity = (tenantId: string) => {
|
||||
// TODO: This requires time-series data aggregation from production batches
|
||||
// For now, returning empty until backend provides hourly aggregation endpoint
|
||||
|
||||
return useQuery<HourlyProductivity[]>({
|
||||
queryKey: ['performance', 'hourly', tenantId],
|
||||
queryFn: async () => {
|
||||
// Placeholder - backend endpoint needed for real hourly data
|
||||
return [];
|
||||
},
|
||||
enabled: false, // Disable until backend endpoint is ready
|
||||
});
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Cross-Functional Performance Metrics
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Cycle Time: Order-to-Delivery
|
||||
* Measures the complete time from order creation to delivery
|
||||
*/
|
||||
export const useCycleTimeMetrics = (tenantId: string, period: TimePeriod = 'week') => {
|
||||
const { data: orders, isLoading: ordersLoading } = useOrdersDashboard(tenantId);
|
||||
const { data: production, isLoading: productionLoading } = useProductionPerformance(tenantId, period);
|
||||
|
||||
// Extract primitive values before useMemo to prevent unnecessary recalculations
|
||||
const totalOrders = orders?.total_orders_today || 1;
|
||||
const deliveredOrders = orders?.delivered_orders || 0;
|
||||
const pendingOrders = orders?.pending_orders || 0;
|
||||
const avgProductionTime = production?.average_batch_time || 2;
|
||||
const onTimeCompletionRate = production?.on_time_completion_rate || 0;
|
||||
|
||||
const cycleTime = useMemo(() => {
|
||||
if (!orders || !production) return undefined;
|
||||
|
||||
// Estimate average cycle time based on fulfillment rate and production efficiency
|
||||
const fulfillmentRate = (deliveredOrders / totalOrders) * 100;
|
||||
|
||||
// Estimated total cycle time includes: order processing + production + delivery
|
||||
const estimatedCycleTime = avgProductionTime + (pendingOrders > 0 ? 1.5 : 0.5); // Add wait time
|
||||
|
||||
return {
|
||||
average_cycle_time: estimatedCycleTime,
|
||||
order_to_production_time: 0.5, // Order processing time
|
||||
production_time: avgProductionTime,
|
||||
production_to_delivery_time: pendingOrders > 0 ? 1.0 : 0.3,
|
||||
fulfillment_rate: fulfillmentRate,
|
||||
on_time_delivery_rate: onTimeCompletionRate,
|
||||
};
|
||||
}, [totalOrders, deliveredOrders, pendingOrders, avgProductionTime, onTimeCompletionRate]);
|
||||
|
||||
return {
|
||||
data: cycleTime,
|
||||
isLoading: ordersLoading || productionLoading,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Process Efficiency Score
|
||||
* Combined efficiency across all departments
|
||||
*/
|
||||
export const useProcessEfficiencyScore = (tenantId: string, period: TimePeriod = 'week') => {
|
||||
const { data: production, isLoading: productionLoading } = useProductionPerformance(tenantId, period);
|
||||
const { data: inventory, isLoading: inventoryLoading } = useInventoryPerformance(tenantId);
|
||||
const { data: procurement, isLoading: procurementLoading } = useProcurementPerformance(tenantId);
|
||||
const { data: orders, isLoading: ordersLoading } = useOrdersDashboard(tenantId);
|
||||
|
||||
// Extract primitive values before useMemo to prevent unnecessary recalculations
|
||||
const productionEfficiency = production?.efficiency || 0;
|
||||
const inventoryStockAccuracy = inventory?.stock_accuracy || 0;
|
||||
const procurementFulfillmentRate = procurement?.fulfillment_rate || 0;
|
||||
const totalOrders = orders?.total_orders_today || 1;
|
||||
const deliveredOrders = orders?.delivered_orders || 0;
|
||||
|
||||
const score = useMemo(() => {
|
||||
if (!production || !inventory || !procurement || !orders) return undefined;
|
||||
|
||||
// Weighted efficiency score across departments
|
||||
const productionWeight = 0.35;
|
||||
const inventoryWeight = 0.25;
|
||||
const procurementWeight = 0.25;
|
||||
const ordersWeight = 0.15;
|
||||
|
||||
const orderEfficiency = (deliveredOrders / totalOrders) * 100;
|
||||
|
||||
const overallScore =
|
||||
(productionEfficiency * productionWeight) +
|
||||
(inventoryStockAccuracy * inventoryWeight) +
|
||||
(procurementFulfillmentRate * procurementWeight) +
|
||||
(orderEfficiency * ordersWeight);
|
||||
|
||||
return {
|
||||
overall_score: overallScore,
|
||||
production_efficiency: productionEfficiency,
|
||||
inventory_efficiency: inventoryStockAccuracy,
|
||||
procurement_efficiency: procurementFulfillmentRate,
|
||||
order_efficiency: orderEfficiency,
|
||||
breakdown: {
|
||||
production: { value: productionEfficiency, weight: productionWeight * 100 },
|
||||
inventory: { value: inventoryStockAccuracy, weight: inventoryWeight * 100 },
|
||||
procurement: { value: procurementFulfillmentRate, weight: procurementWeight * 100 },
|
||||
orders: { value: orderEfficiency, weight: ordersWeight * 100 },
|
||||
},
|
||||
};
|
||||
}, [productionEfficiency, inventoryStockAccuracy, procurementFulfillmentRate, totalOrders, deliveredOrders]);
|
||||
|
||||
return {
|
||||
data: score,
|
||||
isLoading: productionLoading || inventoryLoading || procurementLoading || ordersLoading,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Resource Utilization Rate
|
||||
* Cross-departmental resource balance and utilization
|
||||
*/
|
||||
export const useResourceUtilization = (tenantId: string, period: TimePeriod = 'week') => {
|
||||
const { data: production, isLoading: productionLoading } = useProductionPerformance(tenantId, period);
|
||||
const { data: inventory, isLoading: inventoryLoading } = useInventoryPerformance(tenantId);
|
||||
|
||||
// Extract primitive values before useMemo to prevent unnecessary recalculations
|
||||
const equipmentEfficiency = production?.equipment_efficiency || 0;
|
||||
const turnoverRate = inventory?.turnover_rate || 0;
|
||||
const stockAccuracy = inventory?.stock_accuracy || 0;
|
||||
const capacityUtilization = production?.capacity_utilization || 0;
|
||||
|
||||
const utilization = useMemo(() => {
|
||||
if (!production || !inventory) return undefined;
|
||||
|
||||
// Equipment utilization from production
|
||||
const equipmentUtilization = equipmentEfficiency;
|
||||
|
||||
// Inventory utilization based on turnover and stock levels
|
||||
const inventoryUtilization = turnoverRate > 0
|
||||
? Math.min(turnoverRate * 10, 100) // Normalize turnover to percentage
|
||||
: stockAccuracy;
|
||||
|
||||
// Combined resource utilization
|
||||
const overallUtilization = (equipmentUtilization + inventoryUtilization) / 2;
|
||||
|
||||
return {
|
||||
overall_utilization: overallUtilization,
|
||||
equipment_utilization: equipmentUtilization,
|
||||
inventory_utilization: inventoryUtilization,
|
||||
capacity_used: capacityUtilization,
|
||||
resource_balance: Math.abs(equipmentUtilization - inventoryUtilization) < 10 ? 'balanced' : 'imbalanced',
|
||||
};
|
||||
}, [equipmentEfficiency, turnoverRate, stockAccuracy, capacityUtilization]);
|
||||
|
||||
return {
|
||||
data: utilization,
|
||||
isLoading: productionLoading || inventoryLoading,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Cost-to-Revenue Ratio
|
||||
* Overall profitability metric
|
||||
*/
|
||||
export const useCostRevenueRatio = (tenantId: string, period: TimePeriod = 'week') => {
|
||||
const { data: sales, isLoading: salesLoading } = useSalesPerformance(tenantId, period);
|
||||
const { data: inventory, isLoading: inventoryLoading } = useInventoryPerformance(tenantId);
|
||||
const { data: production, isLoading: productionLoading } = useProductionPerformance(tenantId, period);
|
||||
|
||||
// Extract primitive values before useMemo to prevent unnecessary recalculations
|
||||
const totalRevenue = sales?.total_revenue || 0;
|
||||
const stockValue = inventory?.stock_value || 0;
|
||||
const wastePercentage = production?.waste_percentage || 0;
|
||||
|
||||
const ratio = useMemo(() => {
|
||||
if (!sales || !inventory || !production) return undefined;
|
||||
|
||||
// Estimate costs from inventory value and waste
|
||||
const inventoryCosts = stockValue * 0.1; // Approximate monthly inventory cost
|
||||
const wasteCosts = (stockValue * wastePercentage) / 100;
|
||||
const estimatedTotalCosts = inventoryCosts + wasteCosts;
|
||||
|
||||
const costRevenueRatio = totalRevenue > 0 ? (estimatedTotalCosts / totalRevenue) * 100 : 0;
|
||||
const profitMargin = totalRevenue > 0 ? ((totalRevenue - estimatedTotalCosts) / totalRevenue) * 100 : 0;
|
||||
|
||||
return {
|
||||
cost_revenue_ratio: costRevenueRatio,
|
||||
profit_margin: profitMargin,
|
||||
total_revenue: totalRevenue,
|
||||
estimated_costs: estimatedTotalCosts,
|
||||
inventory_costs: inventoryCosts,
|
||||
waste_costs: wasteCosts,
|
||||
};
|
||||
}, [totalRevenue, stockValue, wastePercentage]);
|
||||
|
||||
return {
|
||||
data: ratio,
|
||||
isLoading: salesLoading || inventoryLoading || productionLoading,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Quality Impact Index
|
||||
* Quality issues across all departments
|
||||
*/
|
||||
export const useQualityImpactIndex = (tenantId: string, period: TimePeriod = 'week') => {
|
||||
const { data: production, isLoading: productionLoading } = useProductionPerformance(tenantId, period);
|
||||
const { data: inventory, isLoading: inventoryLoading } = useInventoryPerformance(tenantId);
|
||||
|
||||
// Extract primitive values before useMemo to prevent unnecessary recalculations
|
||||
const productionQuality = production?.quality_rate || 0;
|
||||
const wasteImpact = production?.waste_percentage || 0;
|
||||
const expiringItems = inventory?.expiring_items_count || 0;
|
||||
const lowStockItems = inventory?.low_stock_count || 0;
|
||||
|
||||
const qualityIndex = useMemo(() => {
|
||||
if (!production || !inventory) return undefined;
|
||||
|
||||
// Inventory quality
|
||||
const totalItems = (expiringItems + lowStockItems) || 1;
|
||||
const inventoryQualityScore = 100 - ((expiringItems + lowStockItems) / totalItems * 10);
|
||||
|
||||
// Combined quality index (weighted average)
|
||||
const overallQuality = (productionQuality * 0.7) + (inventoryQualityScore * 0.3);
|
||||
|
||||
return {
|
||||
overall_quality_index: overallQuality,
|
||||
production_quality: productionQuality,
|
||||
inventory_quality: inventoryQualityScore,
|
||||
waste_impact: wasteImpact,
|
||||
quality_issues: {
|
||||
production_defects: 100 - productionQuality,
|
||||
waste_percentage: wasteImpact,
|
||||
expiring_items: expiringItems,
|
||||
low_stock_affecting_quality: lowStockItems,
|
||||
},
|
||||
};
|
||||
}, [productionQuality, wasteImpact, expiringItems, lowStockItems]);
|
||||
|
||||
return {
|
||||
data: qualityIndex,
|
||||
isLoading: productionLoading || inventoryLoading,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Critical Bottlenecks
|
||||
* Identifies process bottlenecks across operations
|
||||
*/
|
||||
export const useCriticalBottlenecks = (tenantId: string, period: TimePeriod = 'week') => {
|
||||
const { data: production, isLoading: productionLoading } = useProductionPerformance(tenantId, period);
|
||||
const { data: inventory, isLoading: inventoryLoading } = useInventoryPerformance(tenantId);
|
||||
const { data: procurement, isLoading: procurementLoading } = useProcurementPerformance(tenantId);
|
||||
const { data: orders, isLoading: ordersLoading } = useOrdersDashboard(tenantId);
|
||||
|
||||
// Extract primitive values before useMemo to prevent unnecessary recalculations
|
||||
const capacityUtilization = production?.capacity_utilization || 0;
|
||||
const onTimeCompletionRate = production?.on_time_completion_rate || 0;
|
||||
const lowStockCount = inventory?.low_stock_count || 0;
|
||||
const criticalRequirements = procurement?.critical_requirements || 0;
|
||||
const onTimeDeliveryRate = procurement?.on_time_delivery_rate || 0;
|
||||
const totalOrders = orders?.total_orders_today || 1;
|
||||
const pendingOrders = orders?.pending_orders || 0;
|
||||
|
||||
const bottlenecks = useMemo(() => {
|
||||
if (!production || !inventory || !procurement || !orders) return undefined;
|
||||
|
||||
const bottlenecksList = [];
|
||||
|
||||
// Production bottlenecks
|
||||
if (capacityUtilization > 90) {
|
||||
bottlenecksList.push({
|
||||
area: 'production',
|
||||
severity: 'high',
|
||||
description: 'Capacidad de producción al límite',
|
||||
metric: 'capacity_utilization',
|
||||
value: capacityUtilization,
|
||||
});
|
||||
}
|
||||
|
||||
if (onTimeCompletionRate < 85) {
|
||||
bottlenecksList.push({
|
||||
area: 'production',
|
||||
severity: 'medium',
|
||||
description: 'Retrasos en completitud de producción',
|
||||
metric: 'on_time_completion',
|
||||
value: onTimeCompletionRate,
|
||||
});
|
||||
}
|
||||
|
||||
// Inventory bottlenecks
|
||||
if (lowStockCount > 10) {
|
||||
bottlenecksList.push({
|
||||
area: 'inventory',
|
||||
severity: 'high',
|
||||
description: 'Alto número de ingredientes con stock bajo',
|
||||
metric: 'low_stock_count',
|
||||
value: lowStockCount,
|
||||
});
|
||||
}
|
||||
|
||||
// Procurement bottlenecks
|
||||
if (criticalRequirements > 5) {
|
||||
bottlenecksList.push({
|
||||
area: 'procurement',
|
||||
severity: 'high',
|
||||
description: 'Requisitos de compra críticos pendientes',
|
||||
metric: 'critical_requirements',
|
||||
value: criticalRequirements,
|
||||
});
|
||||
}
|
||||
|
||||
if (onTimeDeliveryRate < 85) {
|
||||
bottlenecksList.push({
|
||||
area: 'procurement',
|
||||
severity: 'medium',
|
||||
description: 'Entregas de proveedores retrasadas',
|
||||
metric: 'on_time_delivery',
|
||||
value: onTimeDeliveryRate,
|
||||
});
|
||||
}
|
||||
|
||||
// Orders bottlenecks
|
||||
const pendingRate = (pendingOrders / totalOrders) * 100;
|
||||
|
||||
if (pendingRate > 30) {
|
||||
bottlenecksList.push({
|
||||
area: 'orders',
|
||||
severity: 'medium',
|
||||
description: 'Alto volumen de pedidos pendientes',
|
||||
metric: 'pending_orders',
|
||||
value: pendingOrders,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
total_bottlenecks: bottlenecksList.length,
|
||||
critical_count: bottlenecksList.filter(b => b.severity === 'high').length,
|
||||
bottlenecks: bottlenecksList,
|
||||
most_critical_area: bottlenecksList.length > 0
|
||||
? bottlenecksList.sort((a, b) => {
|
||||
const severityOrder = { high: 0, medium: 1, low: 2 };
|
||||
return severityOrder[a.severity as 'high' | 'medium' | 'low'] - severityOrder[b.severity as 'high' | 'medium' | 'low'];
|
||||
})[0].area
|
||||
: null,
|
||||
};
|
||||
}, [capacityUtilization, onTimeCompletionRate, lowStockCount, criticalRequirements, onTimeDeliveryRate, totalOrders, pendingOrders]);
|
||||
|
||||
return {
|
||||
data: bottlenecks,
|
||||
isLoading: productionLoading || inventoryLoading || procurementLoading || ordersLoading,
|
||||
};
|
||||
};
|
||||
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,
|
||||
});
|
||||
};
|
||||
@@ -72,7 +72,7 @@ export const useSubscription = () => {
|
||||
error: 'Failed to load subscription data'
|
||||
}));
|
||||
}
|
||||
}, [tenantId, notifySubscriptionChanged]);
|
||||
}, [tenantId]); // Removed notifySubscriptionChanged - it's now stable from context
|
||||
|
||||
useEffect(() => {
|
||||
loadSubscriptionData();
|
||||
|
||||
@@ -339,3 +339,35 @@ export const useRemoveTeamMember = (
|
||||
...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,
|
||||
});
|
||||
};
|
||||
@@ -27,7 +27,7 @@ export { posService } from './services/pos';
|
||||
export { recipesService } from './services/recipes';
|
||||
|
||||
// NEW: Sprint 2 & 3 Services
|
||||
export * as procurementService from './services/procurement';
|
||||
export { ProcurementService } from './services/procurement-service';
|
||||
export * as orchestratorService from './services/orchestrator';
|
||||
|
||||
// Types - Auth
|
||||
@@ -289,31 +289,55 @@ export type {
|
||||
GetCustomersParams,
|
||||
UpdateOrderStatusParams,
|
||||
GetDemandRequirementsParams,
|
||||
// Procurement types
|
||||
} from './types/orders';
|
||||
|
||||
// Types - Procurement
|
||||
export type {
|
||||
// Enums
|
||||
ProcurementPlanType,
|
||||
ProcurementStrategy,
|
||||
RiskLevel,
|
||||
RequirementStatus,
|
||||
PlanStatus,
|
||||
DeliveryStatus,
|
||||
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,
|
||||
ForecastRequest as ProcurementForecastRequest,
|
||||
|
||||
// Query params
|
||||
GetProcurementPlansParams,
|
||||
GetPlanRequirementsParams,
|
||||
UpdatePlanStatusParams,
|
||||
} from './types/orders';
|
||||
} from './types/procurement';
|
||||
|
||||
// Types - Forecasting
|
||||
export type {
|
||||
@@ -609,26 +633,34 @@ export {
|
||||
useCreateCustomer,
|
||||
useUpdateCustomer,
|
||||
useInvalidateOrders,
|
||||
// Procurement hooks
|
||||
ordersKeys,
|
||||
} from './hooks/orders';
|
||||
|
||||
// Hooks - Procurement
|
||||
export {
|
||||
// Queries
|
||||
useProcurementDashboard,
|
||||
useProcurementPlans,
|
||||
useProcurementPlan,
|
||||
useProcurementPlanByDate,
|
||||
useCurrentProcurementPlan,
|
||||
useProcurementDashboard,
|
||||
usePlanRequirements,
|
||||
useCriticalRequirements,
|
||||
useProcurementHealth,
|
||||
|
||||
// Mutations
|
||||
useGenerateProcurementPlan,
|
||||
useAutoGenerateProcurement,
|
||||
useUpdateProcurementPlanStatus,
|
||||
useTriggerDailyScheduler,
|
||||
useRecalculateProcurementPlan,
|
||||
useApproveProcurementPlan,
|
||||
useRejectProcurementPlan,
|
||||
useCreatePurchaseOrdersFromPlan,
|
||||
useLinkRequirementToPurchaseOrder,
|
||||
useUpdateRequirementDeliveryStatus,
|
||||
ordersKeys,
|
||||
} from './hooks/orders';
|
||||
|
||||
// Query keys
|
||||
procurementKeys,
|
||||
} from './hooks/procurement';
|
||||
|
||||
// Hooks - Forecasting
|
||||
export {
|
||||
|
||||
@@ -28,24 +28,6 @@ import {
|
||||
GetCustomersParams,
|
||||
UpdateOrderStatusParams,
|
||||
GetDemandRequirementsParams,
|
||||
// Procurement types
|
||||
ProcurementPlanResponse,
|
||||
ProcurementPlanCreate,
|
||||
ProcurementPlanUpdate,
|
||||
ProcurementRequirementResponse,
|
||||
ProcurementRequirementUpdate,
|
||||
ProcurementDashboardData,
|
||||
GeneratePlanRequest,
|
||||
GeneratePlanResponse,
|
||||
PaginatedProcurementPlans,
|
||||
GetProcurementPlansParams,
|
||||
GetPlanRequirementsParams,
|
||||
UpdatePlanStatusParams,
|
||||
CreatePOsResult,
|
||||
LinkRequirementToPORequest,
|
||||
UpdateDeliveryStatusRequest,
|
||||
ApprovalRequest,
|
||||
RejectionRequest,
|
||||
} from '../types/orders';
|
||||
|
||||
export class OrdersService {
|
||||
@@ -209,209 +191,6 @@ export class OrdersService {
|
||||
return apiClient.get<ServiceStatus>(`/tenants/${tenantId}/orders/operations/status`);
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// OPERATIONS: Procurement Planning
|
||||
// Backend: services/orders/app/api/procurement_operations.py
|
||||
// ===================================================================
|
||||
|
||||
/**
|
||||
* Get current procurement plan for today
|
||||
* GET /tenants/{tenant_id}/orders/operations/procurement/plans/current
|
||||
*/
|
||||
static async getCurrentProcurementPlan(tenantId: string): Promise<ProcurementPlanResponse | null> {
|
||||
return apiClient.get<ProcurementPlanResponse | null>(`/tenants/${tenantId}/orders/operations/procurement/plans/current`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get procurement plan by specific date
|
||||
* GET /tenants/{tenant_id}/orders/operations/procurement/plans/date/{plan_date}
|
||||
*/
|
||||
static async getProcurementPlanByDate(tenantId: string, planDate: string): Promise<ProcurementPlanResponse | null> {
|
||||
return apiClient.get<ProcurementPlanResponse | null>(`/tenants/${tenantId}/orders/operations/procurement/plans/date/${planDate}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get procurement plan by ID
|
||||
* GET /tenants/{tenant_id}/orders/operations/procurement/plans/id/{plan_id}
|
||||
*/
|
||||
static async getProcurementPlanById(tenantId: string, planId: string): Promise<ProcurementPlanResponse | null> {
|
||||
return apiClient.get<ProcurementPlanResponse | null>(`/tenants/${tenantId}/orders/operations/procurement/plans/id/${planId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* List procurement plans with filtering
|
||||
* GET /tenants/{tenant_id}/orders/operations/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}/orders/operations/procurement/plans?${queryParams.toString()}`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a new procurement plan
|
||||
* POST /tenants/{tenant_id}/orders/operations/procurement/plans/generate
|
||||
*/
|
||||
static async generateProcurementPlan(tenantId: string, request: GeneratePlanRequest): Promise<GeneratePlanResponse> {
|
||||
return apiClient.post<GeneratePlanResponse>(`/tenants/${tenantId}/orders/operations/procurement/plans/generate`, request);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update procurement plan status
|
||||
* PUT /tenants/{tenant_id}/orders/operations/procurement/plans/{plan_id}/status
|
||||
*/
|
||||
static async updateProcurementPlanStatus(params: UpdatePlanStatusParams): Promise<ProcurementPlanResponse> {
|
||||
const { tenant_id, plan_id, status } = params;
|
||||
|
||||
const queryParams = new URLSearchParams({ status });
|
||||
|
||||
return apiClient.put<ProcurementPlanResponse>(
|
||||
`/tenants/${tenant_id}/orders/operations/procurement/plans/${plan_id}/status?${queryParams.toString()}`,
|
||||
{}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get procurement dashboard data
|
||||
* GET /tenants/{tenant_id}/orders/dashboard/procurement
|
||||
*/
|
||||
static async getProcurementDashboard(tenantId: string): Promise<ProcurementDashboardData | null> {
|
||||
return apiClient.get<ProcurementDashboardData | null>(`/tenants/${tenantId}/orders/dashboard/procurement`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get requirements for a specific plan
|
||||
* GET /tenants/{tenant_id}/orders/operations/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}/orders/operations/procurement/plans/${plan_id}/requirements${queryParams.toString() ? `?${queryParams.toString()}` : ''}`;
|
||||
|
||||
return apiClient.get<ProcurementRequirementResponse[]>(url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get critical requirements across all plans
|
||||
* GET /tenants/{tenant_id}/orders/operations/procurement/requirements/critical
|
||||
*/
|
||||
static async getCriticalRequirements(tenantId: string): Promise<ProcurementRequirementResponse[]> {
|
||||
return apiClient.get<ProcurementRequirementResponse[]>(`/tenants/${tenantId}/orders/operations/procurement/requirements/critical`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger daily scheduler manually
|
||||
* POST /tenants/{tenant_id}/orders/operations/procurement/scheduler/trigger
|
||||
*/
|
||||
static async triggerDailyScheduler(tenantId: string): Promise<{ success: boolean; message: string; tenant_id: string }> {
|
||||
return apiClient.post<{ success: boolean; message: string; tenant_id: string }>(
|
||||
`/tenants/${tenantId}/orders/operations/procurement/scheduler/trigger`,
|
||||
{}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get procurement service health
|
||||
* GET /tenants/{tenant_id}/orders/base/procurement/health
|
||||
*/
|
||||
static async getProcurementHealth(tenantId: string): Promise<{ status: string; service: string; procurement_enabled: boolean; timestamp: string }> {
|
||||
return apiClient.get<{ status: string; service: string; procurement_enabled: boolean; timestamp: string }>(`/tenants/${tenantId}/orders/base/procurement/health`);
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// OPERATIONS: Advanced Procurement Features
|
||||
// Backend: services/orders/app/api/procurement_operations.py
|
||||
// ===================================================================
|
||||
|
||||
/**
|
||||
* Recalculate an existing procurement plan
|
||||
* POST /tenants/{tenant_id}/orders/operations/procurement/plans/{plan_id}/recalculate
|
||||
*/
|
||||
static async recalculateProcurementPlan(tenantId: string, planId: string): Promise<GeneratePlanResponse> {
|
||||
return apiClient.post<GeneratePlanResponse>(
|
||||
`/tenants/${tenantId}/orders/operations/procurement/plans/${planId}/recalculate`,
|
||||
{}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Approve a procurement plan with notes
|
||||
* POST /tenants/{tenant_id}/orders/operations/procurement/plans/{plan_id}/approve
|
||||
*/
|
||||
static async approveProcurementPlan(tenantId: string, planId: string, request?: ApprovalRequest): Promise<ProcurementPlanResponse> {
|
||||
return apiClient.post<ProcurementPlanResponse>(
|
||||
`/tenants/${tenantId}/orders/operations/procurement/plans/${planId}/approve`,
|
||||
request || {}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject a procurement plan with notes
|
||||
* POST /tenants/{tenant_id}/orders/operations/procurement/plans/{plan_id}/reject
|
||||
*/
|
||||
static async rejectProcurementPlan(tenantId: string, planId: string, request?: RejectionRequest): Promise<ProcurementPlanResponse> {
|
||||
return apiClient.post<ProcurementPlanResponse>(
|
||||
`/tenants/${tenantId}/orders/operations/procurement/plans/${planId}/reject`,
|
||||
request || {}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create purchase orders automatically from procurement plan
|
||||
* POST /tenants/{tenant_id}/orders/operations/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}/orders/operations/procurement/plans/${planId}/create-purchase-orders`,
|
||||
{ auto_approve: autoApprove }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Link a procurement requirement to a purchase order
|
||||
* POST /tenants/{tenant_id}/orders/operations/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}/orders/operations/procurement/requirements/${requirementId}/link-purchase-order`,
|
||||
request
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update delivery status for a requirement
|
||||
* PUT /tenants/{tenant_id}/orders/operations/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}/orders/operations/procurement/requirements/${requirementId}/delivery-status`,
|
||||
request
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default OrdersService;
|
||||
|
||||
335
frontend/src/api/services/procurement-service.ts
Normal file
335
frontend/src/api/services/procurement-service.ts
Normal file
@@ -0,0 +1,335 @@
|
||||
// ================================================================
|
||||
// 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/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/auto-generate`,
|
||||
request
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a new procurement plan (manual/UI-driven)
|
||||
* POST /api/v1/tenants/{tenant_id}/procurement/plans/generate
|
||||
*/
|
||||
static async generateProcurementPlan(
|
||||
tenantId: string,
|
||||
request: GeneratePlanRequest
|
||||
): Promise<GeneratePlanResponse> {
|
||||
return apiClient.post<GeneratePlanResponse>(
|
||||
`/tenants/${tenantId}/procurement/plans/generate`,
|
||||
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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default ProcurementService;
|
||||
@@ -1,317 +0,0 @@
|
||||
/**
|
||||
* Procurement Service API Client
|
||||
* Handles procurement planning and purchase order management
|
||||
*
|
||||
* NEW in Sprint 3: Procurement Service now owns all procurement operations
|
||||
* Previously these were split between Orders Service and Suppliers Service
|
||||
*/
|
||||
|
||||
import { apiClient } from '../client';
|
||||
|
||||
// ============================================================================
|
||||
// PROCUREMENT PLAN TYPES
|
||||
// ============================================================================
|
||||
|
||||
export interface ProcurementRequirement {
|
||||
id: string;
|
||||
ingredient_id: string;
|
||||
ingredient_name?: string;
|
||||
ingredient_sku?: string;
|
||||
required_quantity: number;
|
||||
current_stock: number;
|
||||
quantity_to_order: number;
|
||||
unit_of_measure: string;
|
||||
estimated_cost: string; // Decimal as string
|
||||
priority: 'urgent' | 'high' | 'normal' | 'low';
|
||||
reason: string;
|
||||
supplier_id?: string;
|
||||
supplier_name?: string;
|
||||
expected_delivery_date?: string;
|
||||
// NEW: Local production support
|
||||
is_locally_produced?: boolean;
|
||||
recipe_id?: string;
|
||||
parent_requirement_id?: string;
|
||||
bom_explosion_level?: number;
|
||||
}
|
||||
|
||||
export interface ProcurementPlanSummary {
|
||||
id: string;
|
||||
plan_date: string;
|
||||
status: 'DRAFT' | 'PENDING_APPROVAL' | 'APPROVED' | 'IN_PROGRESS' | 'COMPLETED' | 'CANCELLED';
|
||||
total_requirements: number;
|
||||
total_estimated_cost: string; // Decimal as string
|
||||
planning_horizon_days: number;
|
||||
auto_generated: boolean;
|
||||
// NEW: Orchestrator integration
|
||||
forecast_id?: string;
|
||||
production_schedule_id?: string;
|
||||
created_at: string;
|
||||
created_by?: string;
|
||||
}
|
||||
|
||||
export interface ProcurementPlanDetail extends ProcurementPlanSummary {
|
||||
requirements: ProcurementRequirement[];
|
||||
notes?: string;
|
||||
approved_by?: string;
|
||||
approved_at?: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// AUTO-GENERATE PROCUREMENT TYPES (Orchestrator Integration)
|
||||
// ============================================================================
|
||||
|
||||
export interface AutoGenerateProcurementRequest {
|
||||
forecast_data: Record<string, any>; // From Forecasting Service
|
||||
production_schedule_id?: string;
|
||||
target_date?: string; // YYYY-MM-DD
|
||||
planning_horizon_days?: number; // Default: 14
|
||||
safety_stock_percentage?: number; // Default: 20.00
|
||||
auto_create_pos?: boolean; // Default: true
|
||||
auto_approve_pos?: boolean; // Default: false
|
||||
}
|
||||
|
||||
export interface AutoGenerateProcurementResponse {
|
||||
success: boolean;
|
||||
plan?: ProcurementPlanDetail;
|
||||
purchase_orders_created?: number;
|
||||
purchase_orders_auto_approved?: number;
|
||||
purchase_orders_pending_approval?: number;
|
||||
recipe_explosion_applied?: boolean;
|
||||
recipe_explosion_metadata?: {
|
||||
total_requirements_before: number;
|
||||
total_requirements_after: number;
|
||||
explosion_levels: number;
|
||||
locally_produced_ingredients: number;
|
||||
};
|
||||
warnings?: string[];
|
||||
errors?: string[];
|
||||
execution_time_ms?: number;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// PROCUREMENT PLAN API FUNCTIONS
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get list of procurement plans with optional filters
|
||||
*/
|
||||
export async function listProcurementPlans(
|
||||
tenantId: string,
|
||||
params?: {
|
||||
status?: ProcurementPlanSummary['status'];
|
||||
date_from?: string;
|
||||
date_to?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
): Promise<ProcurementPlanSummary[]> {
|
||||
return apiClient.get<ProcurementPlanSummary[]>(
|
||||
`/tenants/${tenantId}/procurement/plans`,
|
||||
{ params }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single procurement plan by ID with full details
|
||||
*/
|
||||
export async function getProcurementPlan(
|
||||
tenantId: string,
|
||||
planId: string
|
||||
): Promise<ProcurementPlanDetail> {
|
||||
return apiClient.get<ProcurementPlanDetail>(
|
||||
`/tenants/${tenantId}/procurement/plans/${planId}`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new procurement plan (manual)
|
||||
*/
|
||||
export async function createProcurementPlan(
|
||||
tenantId: string,
|
||||
data: {
|
||||
plan_date: string;
|
||||
planning_horizon_days?: number;
|
||||
include_safety_stock?: boolean;
|
||||
safety_stock_percentage?: number;
|
||||
notes?: string;
|
||||
}
|
||||
): Promise<ProcurementPlanDetail> {
|
||||
return apiClient.post<ProcurementPlanDetail>(
|
||||
`/tenants/${tenantId}/procurement/plans`,
|
||||
data
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update procurement plan
|
||||
*/
|
||||
export async function updateProcurementPlan(
|
||||
tenantId: string,
|
||||
planId: string,
|
||||
data: {
|
||||
status?: ProcurementPlanSummary['status'];
|
||||
notes?: string;
|
||||
}
|
||||
): Promise<ProcurementPlanDetail> {
|
||||
return apiClient.put<ProcurementPlanDetail>(
|
||||
`/tenants/${tenantId}/procurement/plans/${planId}`,
|
||||
data
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete procurement plan
|
||||
*/
|
||||
export async function deleteProcurementPlan(
|
||||
tenantId: string,
|
||||
planId: string
|
||||
): Promise<{ message: string }> {
|
||||
return apiClient.delete<{ message: string }>(
|
||||
`/tenants/${tenantId}/procurement/plans/${planId}`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Approve procurement plan
|
||||
*/
|
||||
export async function approveProcurementPlan(
|
||||
tenantId: string,
|
||||
planId: string,
|
||||
notes?: string
|
||||
): Promise<ProcurementPlanDetail> {
|
||||
return apiClient.post<ProcurementPlanDetail>(
|
||||
`/tenants/${tenantId}/procurement/plans/${planId}/approve`,
|
||||
{ notes }
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// AUTO-GENERATE PROCUREMENT (ORCHESTRATOR INTEGRATION)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Auto-generate procurement plan from forecast data
|
||||
* This is the main entry point for orchestrated procurement planning
|
||||
*
|
||||
* NEW in Sprint 3: Called by Orchestrator Service to create procurement plans
|
||||
* based on forecast data and production schedules
|
||||
*
|
||||
* Features:
|
||||
* - Receives forecast data from Forecasting Service (via Orchestrator)
|
||||
* - Calculates procurement requirements using smart calculator
|
||||
* - Applies Recipe Explosion for locally-produced ingredients
|
||||
* - Optionally creates purchase orders
|
||||
* - Optionally auto-approves qualifying POs
|
||||
*/
|
||||
export async function autoGenerateProcurement(
|
||||
tenantId: string,
|
||||
request: AutoGenerateProcurementRequest
|
||||
): Promise<AutoGenerateProcurementResponse> {
|
||||
return apiClient.post<AutoGenerateProcurementResponse>(
|
||||
`/tenants/${tenantId}/procurement/auto-generate`,
|
||||
request
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test auto-generate with sample forecast data (for development/testing)
|
||||
*/
|
||||
export async function testAutoGenerateProcurement(
|
||||
tenantId: string,
|
||||
targetDate?: string
|
||||
): Promise<AutoGenerateProcurementResponse> {
|
||||
return apiClient.post<AutoGenerateProcurementResponse>(
|
||||
`/tenants/${tenantId}/procurement/auto-generate/test`,
|
||||
{ target_date: targetDate }
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// PROCUREMENT REQUIREMENTS API FUNCTIONS
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Add requirement to procurement plan
|
||||
*/
|
||||
export async function addProcurementRequirement(
|
||||
tenantId: string,
|
||||
planId: string,
|
||||
requirement: {
|
||||
ingredient_id: string;
|
||||
required_quantity: number;
|
||||
quantity_to_order: number;
|
||||
priority: ProcurementRequirement['priority'];
|
||||
reason: string;
|
||||
supplier_id?: string;
|
||||
expected_delivery_date?: string;
|
||||
}
|
||||
): Promise<ProcurementRequirement> {
|
||||
return apiClient.post<ProcurementRequirement>(
|
||||
`/tenants/${tenantId}/procurement/plans/${planId}/requirements`,
|
||||
requirement
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update procurement requirement
|
||||
*/
|
||||
export async function updateProcurementRequirement(
|
||||
tenantId: string,
|
||||
planId: string,
|
||||
requirementId: string,
|
||||
data: {
|
||||
quantity_to_order?: number;
|
||||
priority?: ProcurementRequirement['priority'];
|
||||
supplier_id?: string;
|
||||
expected_delivery_date?: string;
|
||||
}
|
||||
): Promise<ProcurementRequirement> {
|
||||
return apiClient.put<ProcurementRequirement>(
|
||||
`/tenants/${tenantId}/procurement/plans/${planId}/requirements/${requirementId}`,
|
||||
data
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete procurement requirement
|
||||
*/
|
||||
export async function deleteProcurementRequirement(
|
||||
tenantId: string,
|
||||
planId: string,
|
||||
requirementId: string
|
||||
): Promise<{ message: string }> {
|
||||
return apiClient.delete<{ message: string }>(
|
||||
`/tenants/${tenantId}/procurement/plans/${planId}/requirements/${requirementId}`
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// PURCHASE ORDERS FROM PLAN
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Create purchase orders from procurement plan
|
||||
* Groups requirements by supplier and creates POs
|
||||
*/
|
||||
export async function createPurchaseOrdersFromPlan(
|
||||
tenantId: string,
|
||||
planId: string,
|
||||
options?: {
|
||||
auto_approve?: boolean;
|
||||
group_by_supplier?: boolean;
|
||||
delivery_date?: string;
|
||||
}
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
purchase_orders_created: number;
|
||||
purchase_orders_auto_approved?: number;
|
||||
purchase_orders_pending_approval?: number;
|
||||
purchase_order_ids: string[];
|
||||
message?: string;
|
||||
}> {
|
||||
return apiClient.post(
|
||||
`/tenants/${tenantId}/procurement/plans/${planId}/create-purchase-orders`,
|
||||
options
|
||||
);
|
||||
}
|
||||
@@ -168,6 +168,21 @@ export class TenantService {
|
||||
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
|
||||
|
||||
@@ -365,369 +365,3 @@ export interface GetDemandRequirementsParams {
|
||||
tenant_id: string;
|
||||
target_date: string;
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// PROCUREMENT ENUMS
|
||||
// ================================================================
|
||||
|
||||
/**
|
||||
* Procurement plan types
|
||||
* Backend: ProcurementPlanType enum in models/enums.py (lines 104-108)
|
||||
*/
|
||||
export enum ProcurementPlanType {
|
||||
REGULAR = 'regular',
|
||||
EMERGENCY = 'emergency',
|
||||
SEASONAL = 'seasonal'
|
||||
}
|
||||
|
||||
/**
|
||||
* Procurement strategies
|
||||
* Backend: ProcurementStrategy enum in models/enums.py (lines 111-115)
|
||||
*/
|
||||
export enum ProcurementStrategy {
|
||||
JUST_IN_TIME = 'just_in_time',
|
||||
BULK = 'bulk',
|
||||
MIXED = 'mixed'
|
||||
}
|
||||
|
||||
/**
|
||||
* Risk level classifications
|
||||
* Backend: RiskLevel enum in models/enums.py (lines 118-123)
|
||||
*/
|
||||
export enum RiskLevel {
|
||||
LOW = 'low',
|
||||
MEDIUM = 'medium',
|
||||
HIGH = 'high',
|
||||
CRITICAL = 'critical'
|
||||
}
|
||||
|
||||
/**
|
||||
* Procurement requirement status
|
||||
* Backend: RequirementStatus enum in models/enums.py (lines 126-133)
|
||||
*/
|
||||
export enum RequirementStatus {
|
||||
PENDING = 'pending',
|
||||
APPROVED = 'approved',
|
||||
ORDERED = 'ordered',
|
||||
PARTIALLY_RECEIVED = 'partially_received',
|
||||
RECEIVED = 'received',
|
||||
CANCELLED = 'cancelled'
|
||||
}
|
||||
|
||||
/**
|
||||
* Procurement plan status
|
||||
* Backend: PlanStatus enum in models/enums.py (lines 136-143)
|
||||
*/
|
||||
export enum PlanStatus {
|
||||
DRAFT = 'draft',
|
||||
PENDING_APPROVAL = 'pending_approval',
|
||||
APPROVED = 'approved',
|
||||
IN_EXECUTION = 'in_execution',
|
||||
COMPLETED = 'completed',
|
||||
CANCELLED = 'cancelled'
|
||||
}
|
||||
|
||||
/**
|
||||
* Delivery status for procurement
|
||||
* Backend: DeliveryStatus enum in models/enums.py (lines 146-151)
|
||||
*/
|
||||
export enum DeliveryStatus {
|
||||
PENDING = 'pending',
|
||||
IN_TRANSIT = 'in_transit',
|
||||
DELIVERED = 'delivered',
|
||||
DELAYED = 'delayed',
|
||||
CANCELLED = 'cancelled'
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// PROCUREMENT TYPES
|
||||
// ================================================================
|
||||
|
||||
// Procurement Requirement Types
|
||||
export interface ProcurementRequirementBase {
|
||||
product_id: string;
|
||||
product_name: string;
|
||||
product_sku?: string;
|
||||
product_category?: string;
|
||||
product_type: string;
|
||||
required_quantity: number;
|
||||
unit_of_measure: string;
|
||||
safety_stock_quantity: number;
|
||||
total_quantity_needed: number;
|
||||
current_stock_level: number;
|
||||
reserved_stock: number;
|
||||
available_stock: number;
|
||||
net_requirement: number;
|
||||
order_demand: number;
|
||||
production_demand: number;
|
||||
forecast_demand: number;
|
||||
buffer_demand: number;
|
||||
required_by_date: string;
|
||||
lead_time_buffer_days: number;
|
||||
suggested_order_date: string;
|
||||
latest_order_date: string;
|
||||
priority: PriorityLevel;
|
||||
risk_level: RiskLevel;
|
||||
preferred_supplier_id?: string;
|
||||
backup_supplier_id?: string;
|
||||
supplier_name?: string;
|
||||
supplier_lead_time_days?: number;
|
||||
minimum_order_quantity?: number;
|
||||
estimated_unit_cost?: number;
|
||||
estimated_total_cost?: number;
|
||||
last_purchase_cost?: number;
|
||||
}
|
||||
|
||||
export interface ProcurementRequirementCreate extends ProcurementRequirementBase {
|
||||
special_requirements?: string;
|
||||
storage_requirements?: string;
|
||||
shelf_life_days?: number;
|
||||
quality_specifications?: Record<string, any>;
|
||||
procurement_notes?: string;
|
||||
}
|
||||
|
||||
export interface ProcurementRequirementUpdate {
|
||||
status?: RequirementStatus;
|
||||
priority?: PriorityLevel;
|
||||
approved_quantity?: number;
|
||||
approved_cost?: number;
|
||||
purchase_order_id?: string;
|
||||
purchase_order_number?: string;
|
||||
ordered_quantity?: number;
|
||||
expected_delivery_date?: string;
|
||||
actual_delivery_date?: string;
|
||||
received_quantity?: number;
|
||||
delivery_status?: DeliveryStatus;
|
||||
procurement_notes?: string;
|
||||
}
|
||||
|
||||
export interface ProcurementRequirementResponse extends ProcurementRequirementBase {
|
||||
id: string;
|
||||
plan_id: string;
|
||||
requirement_number: string;
|
||||
status: RequirementStatus;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
purchase_order_id?: string;
|
||||
purchase_order_number?: string;
|
||||
ordered_quantity: number;
|
||||
ordered_at?: string;
|
||||
expected_delivery_date?: string;
|
||||
actual_delivery_date?: string;
|
||||
received_quantity: number;
|
||||
delivery_status: DeliveryStatus;
|
||||
fulfillment_rate?: number;
|
||||
on_time_delivery?: boolean;
|
||||
quality_rating?: number;
|
||||
approved_quantity?: number;
|
||||
approved_cost?: number;
|
||||
approved_at?: string;
|
||||
approved_by?: string;
|
||||
special_requirements?: string;
|
||||
storage_requirements?: string;
|
||||
shelf_life_days?: number;
|
||||
quality_specifications?: Record<string, any>;
|
||||
procurement_notes?: string;
|
||||
|
||||
// Smart procurement calculation metadata
|
||||
calculation_method?: string;
|
||||
ai_suggested_quantity?: number;
|
||||
adjusted_quantity?: number;
|
||||
adjustment_reason?: string;
|
||||
price_tier_applied?: Record<string, any>;
|
||||
supplier_minimum_applied?: boolean;
|
||||
storage_limit_applied?: boolean;
|
||||
reorder_rule_applied?: boolean;
|
||||
}
|
||||
|
||||
// Procurement Plan Types
|
||||
export interface ProcurementPlanBase {
|
||||
plan_date: string;
|
||||
plan_period_start: string;
|
||||
plan_period_end: string;
|
||||
planning_horizon_days: number;
|
||||
plan_type: ProcurementPlanType;
|
||||
priority: PriorityLevel;
|
||||
business_model?: BusinessModel;
|
||||
procurement_strategy: ProcurementStrategy;
|
||||
safety_stock_buffer: number;
|
||||
supply_risk_level: RiskLevel;
|
||||
demand_forecast_confidence?: number;
|
||||
seasonality_adjustment: number;
|
||||
special_requirements?: string;
|
||||
}
|
||||
|
||||
export interface ProcurementPlanCreate extends ProcurementPlanBase {
|
||||
tenant_id: string;
|
||||
requirements?: ProcurementRequirementCreate[];
|
||||
}
|
||||
|
||||
export interface ProcurementPlanUpdate {
|
||||
status?: PlanStatus;
|
||||
priority?: PriorityLevel;
|
||||
approved_at?: string;
|
||||
approved_by?: string;
|
||||
execution_started_at?: string;
|
||||
execution_completed_at?: string;
|
||||
special_requirements?: string;
|
||||
seasonal_adjustments?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface ApprovalWorkflowEntry {
|
||||
timestamp: string;
|
||||
from_status: string;
|
||||
to_status: string;
|
||||
user_id?: string;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface ProcurementPlanResponse extends ProcurementPlanBase {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
plan_number: string;
|
||||
status: PlanStatus;
|
||||
total_requirements: number;
|
||||
total_estimated_cost: number;
|
||||
total_approved_cost: number;
|
||||
cost_variance: number;
|
||||
total_demand_orders: number;
|
||||
total_demand_quantity: number;
|
||||
total_production_requirements: number;
|
||||
primary_suppliers_count: number;
|
||||
backup_suppliers_count: number;
|
||||
supplier_diversification_score?: number;
|
||||
approved_at?: string;
|
||||
approved_by?: string;
|
||||
execution_started_at?: string;
|
||||
execution_completed_at?: string;
|
||||
fulfillment_rate?: number;
|
||||
on_time_delivery_rate?: number;
|
||||
cost_accuracy?: number;
|
||||
quality_score?: number;
|
||||
approval_workflow?: ApprovalWorkflowEntry[];
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
created_by?: string;
|
||||
updated_by?: string;
|
||||
requirements: ProcurementRequirementResponse[];
|
||||
}
|
||||
|
||||
// Summary and Dashboard Types
|
||||
export interface ProcurementSummary {
|
||||
total_plans: number;
|
||||
active_plans: number;
|
||||
total_requirements: number;
|
||||
pending_requirements: number;
|
||||
critical_requirements: number;
|
||||
total_estimated_cost: number;
|
||||
total_approved_cost: number;
|
||||
cost_variance: number;
|
||||
average_fulfillment_rate?: number;
|
||||
average_on_time_delivery?: number;
|
||||
top_suppliers: Record<string, any>[];
|
||||
critical_items: Record<string, any>[];
|
||||
}
|
||||
|
||||
export interface ProcurementDashboardData {
|
||||
current_plan?: ProcurementPlanResponse;
|
||||
summary: ProcurementSummary;
|
||||
upcoming_deliveries: Record<string, any>[];
|
||||
overdue_requirements: Record<string, any>[];
|
||||
low_stock_alerts: Record<string, any>[];
|
||||
performance_metrics: Record<string, any>;
|
||||
}
|
||||
|
||||
// Request and Response Types
|
||||
export interface GeneratePlanRequest {
|
||||
plan_date?: string;
|
||||
force_regenerate: boolean;
|
||||
planning_horizon_days: number;
|
||||
include_safety_stock: boolean;
|
||||
safety_stock_percentage: number;
|
||||
}
|
||||
|
||||
export interface GeneratePlanResponse {
|
||||
success: boolean;
|
||||
message: string;
|
||||
plan?: ProcurementPlanResponse;
|
||||
warnings: string[];
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
// New Feature Types
|
||||
export interface CreatePOsResult {
|
||||
success: boolean;
|
||||
created_pos: {
|
||||
po_id: string;
|
||||
po_number: string;
|
||||
supplier_id: string;
|
||||
items_count: number;
|
||||
total_amount: number;
|
||||
}[];
|
||||
failed_pos: {
|
||||
supplier_id: string;
|
||||
error: string;
|
||||
}[];
|
||||
total_created: number;
|
||||
total_failed: number;
|
||||
}
|
||||
|
||||
export interface LinkRequirementToPORequest {
|
||||
purchase_order_id: string;
|
||||
purchase_order_number: string;
|
||||
ordered_quantity: number;
|
||||
expected_delivery_date?: string;
|
||||
}
|
||||
|
||||
export interface UpdateDeliveryStatusRequest {
|
||||
delivery_status: string;
|
||||
received_quantity?: number;
|
||||
actual_delivery_date?: string;
|
||||
quality_rating?: number;
|
||||
}
|
||||
|
||||
export interface ApprovalRequest {
|
||||
approval_notes?: string;
|
||||
}
|
||||
|
||||
export interface RejectionRequest {
|
||||
rejection_notes?: string;
|
||||
}
|
||||
|
||||
export interface PaginatedProcurementPlans {
|
||||
plans: ProcurementPlanResponse[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
has_more: boolean;
|
||||
}
|
||||
|
||||
export interface ForecastRequest {
|
||||
target_date: string;
|
||||
horizon_days: number;
|
||||
include_confidence_intervals: boolean;
|
||||
product_ids?: string[];
|
||||
}
|
||||
|
||||
// Query Parameter Types for Procurement
|
||||
export interface GetProcurementPlansParams {
|
||||
tenant_id: string;
|
||||
status?: string;
|
||||
start_date?: string;
|
||||
end_date?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
export interface GetPlanRequirementsParams {
|
||||
tenant_id: string;
|
||||
plan_id: string;
|
||||
status?: string;
|
||||
priority?: string;
|
||||
}
|
||||
|
||||
export interface UpdatePlanStatusParams {
|
||||
tenant_id: string;
|
||||
plan_id: string;
|
||||
status: PlanStatus;
|
||||
}
|
||||
192
frontend/src/api/types/performance.ts
Normal file
192
frontend/src/api/types/performance.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
/**
|
||||
* Performance Analytics Types
|
||||
* Comprehensive types for performance monitoring across all departments
|
||||
*/
|
||||
|
||||
// ============================================================================
|
||||
// Overview Metrics
|
||||
// ============================================================================
|
||||
|
||||
export interface PerformanceOverview {
|
||||
overall_efficiency: number;
|
||||
average_production_time: number;
|
||||
quality_score: number;
|
||||
employee_productivity: number;
|
||||
customer_satisfaction: number;
|
||||
resource_utilization: number;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Department Performance
|
||||
// ============================================================================
|
||||
|
||||
export interface DepartmentPerformance {
|
||||
department_id: string;
|
||||
department_name: string;
|
||||
efficiency: number;
|
||||
trend: 'up' | 'down' | 'stable';
|
||||
metrics: DepartmentMetrics;
|
||||
}
|
||||
|
||||
export interface DepartmentMetrics {
|
||||
primary_metric: MetricValue;
|
||||
secondary_metric: MetricValue;
|
||||
tertiary_metric: MetricValue;
|
||||
}
|
||||
|
||||
export interface MetricValue {
|
||||
label: string;
|
||||
value: number;
|
||||
unit: string;
|
||||
trend?: number;
|
||||
}
|
||||
|
||||
// Production Department
|
||||
export interface ProductionPerformance {
|
||||
efficiency: number;
|
||||
average_batch_time: number;
|
||||
quality_rate: number;
|
||||
waste_percentage: number;
|
||||
capacity_utilization: number;
|
||||
equipment_efficiency: number;
|
||||
on_time_completion_rate: number;
|
||||
yield_rate: number;
|
||||
}
|
||||
|
||||
// Inventory Department
|
||||
export interface InventoryPerformance {
|
||||
stock_accuracy: number;
|
||||
turnover_rate: number;
|
||||
waste_rate: number;
|
||||
low_stock_count: number;
|
||||
compliance_rate: number;
|
||||
expiring_items_count: number;
|
||||
stock_value: number;
|
||||
}
|
||||
|
||||
// Sales Department
|
||||
export interface SalesPerformance {
|
||||
total_revenue: number;
|
||||
total_transactions: number;
|
||||
average_transaction_value: number;
|
||||
growth_rate: number;
|
||||
channel_performance: ChannelPerformance[];
|
||||
top_products: ProductPerformance[];
|
||||
}
|
||||
|
||||
export interface ChannelPerformance {
|
||||
channel: string;
|
||||
revenue: number;
|
||||
transactions: number;
|
||||
percentage: number;
|
||||
}
|
||||
|
||||
export interface ProductPerformance {
|
||||
product_id: string;
|
||||
product_name: string;
|
||||
sales: number;
|
||||
revenue: number;
|
||||
}
|
||||
|
||||
// Procurement/Administration Department
|
||||
export interface ProcurementPerformance {
|
||||
fulfillment_rate: number;
|
||||
on_time_delivery_rate: number;
|
||||
cost_accuracy: number;
|
||||
supplier_performance_score: number;
|
||||
active_plans: number;
|
||||
critical_requirements: number;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// KPI Tracking
|
||||
// ============================================================================
|
||||
|
||||
export interface KPIMetric {
|
||||
id: string;
|
||||
name: string;
|
||||
current_value: number;
|
||||
target_value: number;
|
||||
previous_value: number;
|
||||
unit: string;
|
||||
trend: 'up' | 'down' | 'stable';
|
||||
status: 'good' | 'warning' | 'critical';
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Performance Alerts
|
||||
// ============================================================================
|
||||
|
||||
export interface PerformanceAlert {
|
||||
id: string;
|
||||
type: 'warning' | 'critical' | 'info';
|
||||
department: string;
|
||||
message: string;
|
||||
timestamp: string;
|
||||
metric_affected: string;
|
||||
current_value?: number;
|
||||
threshold_value?: number;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Time-Series Data
|
||||
// ============================================================================
|
||||
|
||||
export interface TimeSeriesData {
|
||||
timestamp: string;
|
||||
value: number;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export interface HourlyProductivity {
|
||||
hour: string;
|
||||
efficiency: number;
|
||||
production_count: number;
|
||||
sales_count: number;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Aggregated Dashboard Data
|
||||
// ============================================================================
|
||||
|
||||
export interface PerformanceDashboard {
|
||||
overview: PerformanceOverview;
|
||||
departments: DepartmentPerformance[];
|
||||
kpis: KPIMetric[];
|
||||
alerts: PerformanceAlert[];
|
||||
hourly_data: HourlyProductivity[];
|
||||
last_updated: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Filter and Query Parameters
|
||||
// ============================================================================
|
||||
|
||||
export type TimePeriod = 'day' | 'week' | 'month' | 'quarter' | 'year';
|
||||
export type MetricType = 'efficiency' | 'productivity' | 'quality' | 'satisfaction';
|
||||
|
||||
export interface PerformanceFilters {
|
||||
period: TimePeriod;
|
||||
metric_type?: MetricType;
|
||||
start_date?: string;
|
||||
end_date?: string;
|
||||
departments?: string[];
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Trend Analysis
|
||||
// ============================================================================
|
||||
|
||||
export interface TrendData {
|
||||
date: string;
|
||||
value: number;
|
||||
comparison_value?: number;
|
||||
}
|
||||
|
||||
export interface PerformanceTrend {
|
||||
metric_name: string;
|
||||
current_period: TrendData[];
|
||||
previous_period: TrendData[];
|
||||
change_percentage: number;
|
||||
trend_direction: 'up' | 'down' | 'stable';
|
||||
}
|
||||
634
frontend/src/api/types/procurement.ts
Normal file
634
frontend/src/api/types/procurement.ts
Normal file
@@ -0,0 +1,634 @@
|
||||
/**
|
||||
* TypeScript types for Procurement Service
|
||||
* Mirrored from backend schemas: services/procurement/app/schemas/procurement_schemas.py
|
||||
* Backend enums: services/shared/app/models/enums.py
|
||||
* Backend API: services/procurement/app/api/
|
||||
*
|
||||
* Coverage:
|
||||
* - Procurement Plans (MRP-style procurement planning)
|
||||
* - Procurement Requirements (demand-driven purchasing)
|
||||
* - Purchase Orders creation from plans
|
||||
* - Analytics & Dashboard
|
||||
* - Auto-generation (Orchestrator integration)
|
||||
*/
|
||||
|
||||
// ================================================================
|
||||
// ENUMS
|
||||
// ================================================================
|
||||
|
||||
/**
|
||||
* Procurement plan types
|
||||
* Backend: ProcurementPlanType enum in models/enums.py
|
||||
*/
|
||||
export enum ProcurementPlanType {
|
||||
REGULAR = 'regular',
|
||||
EMERGENCY = 'emergency',
|
||||
SEASONAL = 'seasonal'
|
||||
}
|
||||
|
||||
/**
|
||||
* Procurement strategies
|
||||
* Backend: ProcurementStrategy enum in models/enums.py
|
||||
*/
|
||||
export enum ProcurementStrategy {
|
||||
JUST_IN_TIME = 'just_in_time',
|
||||
BULK = 'bulk',
|
||||
MIXED = 'mixed'
|
||||
}
|
||||
|
||||
/**
|
||||
* Risk level classifications
|
||||
* Backend: RiskLevel enum in models/enums.py
|
||||
*/
|
||||
export enum RiskLevel {
|
||||
LOW = 'low',
|
||||
MEDIUM = 'medium',
|
||||
HIGH = 'high',
|
||||
CRITICAL = 'critical'
|
||||
}
|
||||
|
||||
/**
|
||||
* Procurement requirement status
|
||||
* Backend: RequirementStatus enum in models/enums.py
|
||||
*/
|
||||
export enum RequirementStatus {
|
||||
PENDING = 'pending',
|
||||
APPROVED = 'approved',
|
||||
ORDERED = 'ordered',
|
||||
PARTIALLY_RECEIVED = 'partially_received',
|
||||
RECEIVED = 'received',
|
||||
CANCELLED = 'cancelled'
|
||||
}
|
||||
|
||||
/**
|
||||
* Procurement plan status
|
||||
* Backend: PlanStatus enum in models/enums.py
|
||||
*/
|
||||
export enum PlanStatus {
|
||||
DRAFT = 'draft',
|
||||
PENDING_APPROVAL = 'pending_approval',
|
||||
APPROVED = 'approved',
|
||||
IN_EXECUTION = 'in_execution',
|
||||
COMPLETED = 'completed',
|
||||
CANCELLED = 'cancelled'
|
||||
}
|
||||
|
||||
/**
|
||||
* Delivery status for procurement
|
||||
* Backend: DeliveryStatus enum in models/enums.py
|
||||
*/
|
||||
export enum DeliveryStatus {
|
||||
PENDING = 'pending',
|
||||
IN_TRANSIT = 'in_transit',
|
||||
DELIVERED = 'delivered',
|
||||
DELAYED = 'delayed',
|
||||
CANCELLED = 'cancelled'
|
||||
}
|
||||
|
||||
/**
|
||||
* Priority level (shared enum)
|
||||
* Backend: PriorityLevel enum in models/enums.py
|
||||
*/
|
||||
export enum PriorityLevel {
|
||||
HIGH = 'high',
|
||||
NORMAL = 'normal',
|
||||
LOW = 'low'
|
||||
}
|
||||
|
||||
/**
|
||||
* Business model (shared enum)
|
||||
* Backend: BusinessModel enum in models/enums.py
|
||||
*/
|
||||
export enum BusinessModel {
|
||||
INDIVIDUAL_BAKERY = 'individual_bakery',
|
||||
CENTRAL_BAKERY = 'central_bakery'
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// PROCUREMENT REQUIREMENT TYPES
|
||||
// ================================================================
|
||||
|
||||
/**
|
||||
* Base procurement requirement
|
||||
* Backend: ProcurementRequirementBase schema
|
||||
*/
|
||||
export interface ProcurementRequirementBase {
|
||||
product_id: string;
|
||||
product_name: string;
|
||||
product_sku?: string;
|
||||
product_category?: string;
|
||||
product_type: string;
|
||||
required_quantity: number;
|
||||
unit_of_measure: string;
|
||||
safety_stock_quantity: number;
|
||||
total_quantity_needed: number;
|
||||
current_stock_level: number;
|
||||
reserved_stock: number;
|
||||
available_stock: number;
|
||||
net_requirement: number;
|
||||
order_demand: number;
|
||||
production_demand: number;
|
||||
forecast_demand: number;
|
||||
buffer_demand: number;
|
||||
required_by_date: string;
|
||||
lead_time_buffer_days: number;
|
||||
suggested_order_date: string;
|
||||
latest_order_date: string;
|
||||
priority: PriorityLevel;
|
||||
risk_level: RiskLevel;
|
||||
preferred_supplier_id?: string;
|
||||
backup_supplier_id?: string;
|
||||
supplier_name?: string;
|
||||
supplier_lead_time_days?: number;
|
||||
minimum_order_quantity?: number;
|
||||
estimated_unit_cost?: number;
|
||||
estimated_total_cost?: number;
|
||||
last_purchase_cost?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create procurement requirement request
|
||||
* Backend: ProcurementRequirementCreate schema
|
||||
*/
|
||||
export interface ProcurementRequirementCreate extends ProcurementRequirementBase {
|
||||
special_requirements?: string;
|
||||
storage_requirements?: string;
|
||||
shelf_life_days?: number;
|
||||
quality_specifications?: Record<string, any>;
|
||||
procurement_notes?: string;
|
||||
|
||||
// Smart procurement calculation metadata
|
||||
calculation_method?: string;
|
||||
ai_suggested_quantity?: number;
|
||||
adjusted_quantity?: number;
|
||||
adjustment_reason?: string;
|
||||
price_tier_applied?: Record<string, any>;
|
||||
supplier_minimum_applied?: boolean;
|
||||
storage_limit_applied?: boolean;
|
||||
reorder_rule_applied?: boolean;
|
||||
|
||||
// Local production support fields
|
||||
is_locally_produced?: boolean;
|
||||
recipe_id?: string;
|
||||
parent_requirement_id?: string;
|
||||
bom_explosion_level?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update procurement requirement request
|
||||
* Backend: ProcurementRequirementUpdate schema
|
||||
*/
|
||||
export interface ProcurementRequirementUpdate {
|
||||
status?: RequirementStatus;
|
||||
priority?: PriorityLevel;
|
||||
approved_quantity?: number;
|
||||
approved_cost?: number;
|
||||
purchase_order_id?: string;
|
||||
purchase_order_number?: string;
|
||||
ordered_quantity?: number;
|
||||
expected_delivery_date?: string;
|
||||
actual_delivery_date?: string;
|
||||
received_quantity?: number;
|
||||
delivery_status?: DeliveryStatus;
|
||||
procurement_notes?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Procurement requirement response
|
||||
* Backend: ProcurementRequirementResponse schema
|
||||
*/
|
||||
export interface ProcurementRequirementResponse extends ProcurementRequirementBase {
|
||||
id: string;
|
||||
plan_id: string;
|
||||
requirement_number: string;
|
||||
status: RequirementStatus;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
purchase_order_id?: string;
|
||||
purchase_order_number?: string;
|
||||
ordered_quantity: number;
|
||||
ordered_at?: string;
|
||||
expected_delivery_date?: string;
|
||||
actual_delivery_date?: string;
|
||||
received_quantity: number;
|
||||
delivery_status: DeliveryStatus;
|
||||
fulfillment_rate?: number;
|
||||
on_time_delivery?: boolean;
|
||||
quality_rating?: number;
|
||||
approved_quantity?: number;
|
||||
approved_cost?: number;
|
||||
approved_at?: string;
|
||||
approved_by?: string;
|
||||
special_requirements?: string;
|
||||
storage_requirements?: string;
|
||||
shelf_life_days?: number;
|
||||
quality_specifications?: Record<string, any>;
|
||||
procurement_notes?: string;
|
||||
|
||||
// Smart procurement calculation metadata
|
||||
calculation_method?: string;
|
||||
ai_suggested_quantity?: number;
|
||||
adjusted_quantity?: number;
|
||||
adjustment_reason?: string;
|
||||
price_tier_applied?: Record<string, any>;
|
||||
supplier_minimum_applied?: boolean;
|
||||
storage_limit_applied?: boolean;
|
||||
reorder_rule_applied?: boolean;
|
||||
|
||||
// Local production support fields
|
||||
is_locally_produced?: boolean;
|
||||
recipe_id?: string;
|
||||
parent_requirement_id?: string;
|
||||
bom_explosion_level?: number;
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// PROCUREMENT PLAN TYPES
|
||||
// ================================================================
|
||||
|
||||
/**
|
||||
* Base procurement plan
|
||||
* Backend: ProcurementPlanBase schema
|
||||
*/
|
||||
export interface ProcurementPlanBase {
|
||||
plan_date: string;
|
||||
plan_period_start: string;
|
||||
plan_period_end: string;
|
||||
planning_horizon_days: number;
|
||||
plan_type: ProcurementPlanType;
|
||||
priority: PriorityLevel;
|
||||
business_model?: BusinessModel;
|
||||
procurement_strategy: ProcurementStrategy;
|
||||
safety_stock_buffer: number;
|
||||
supply_risk_level: RiskLevel;
|
||||
demand_forecast_confidence?: number;
|
||||
seasonality_adjustment: number;
|
||||
special_requirements?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create procurement plan request
|
||||
* Backend: ProcurementPlanCreate schema
|
||||
*/
|
||||
export interface ProcurementPlanCreate extends ProcurementPlanBase {
|
||||
tenant_id: string;
|
||||
requirements?: ProcurementRequirementCreate[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Update procurement plan request
|
||||
* Backend: ProcurementPlanUpdate schema
|
||||
*/
|
||||
export interface ProcurementPlanUpdate {
|
||||
status?: PlanStatus;
|
||||
priority?: PriorityLevel;
|
||||
approved_at?: string;
|
||||
approved_by?: string;
|
||||
execution_started_at?: string;
|
||||
execution_completed_at?: string;
|
||||
special_requirements?: string;
|
||||
seasonal_adjustments?: Record<string, any>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Approval workflow entry
|
||||
* Backend: ApprovalWorkflowEntry (embedded in plan)
|
||||
*/
|
||||
export interface ApprovalWorkflowEntry {
|
||||
timestamp: string;
|
||||
from_status: string;
|
||||
to_status: string;
|
||||
user_id?: string;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Procurement plan response
|
||||
* Backend: ProcurementPlanResponse schema
|
||||
*/
|
||||
export interface ProcurementPlanResponse extends ProcurementPlanBase {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
plan_number: string;
|
||||
status: PlanStatus;
|
||||
total_requirements: number;
|
||||
total_estimated_cost: number;
|
||||
total_approved_cost: number;
|
||||
cost_variance: number;
|
||||
total_demand_orders: number;
|
||||
total_demand_quantity: number;
|
||||
total_production_requirements: number;
|
||||
primary_suppliers_count: number;
|
||||
backup_suppliers_count: number;
|
||||
supplier_diversification_score?: number;
|
||||
approved_at?: string;
|
||||
approved_by?: string;
|
||||
execution_started_at?: string;
|
||||
execution_completed_at?: string;
|
||||
fulfillment_rate?: number;
|
||||
on_time_delivery_rate?: number;
|
||||
cost_accuracy?: number;
|
||||
quality_score?: number;
|
||||
approval_workflow?: ApprovalWorkflowEntry[];
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
created_by?: string;
|
||||
updated_by?: string;
|
||||
requirements: ProcurementRequirementResponse[];
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// ANALYTICS & DASHBOARD TYPES
|
||||
// ================================================================
|
||||
|
||||
/**
|
||||
* Procurement summary metrics
|
||||
* Backend: Returned by analytics endpoints
|
||||
*/
|
||||
export interface ProcurementSummary {
|
||||
total_plans: number;
|
||||
active_plans: number;
|
||||
total_requirements: number;
|
||||
pending_requirements: number;
|
||||
critical_requirements: number;
|
||||
total_estimated_cost: number;
|
||||
total_approved_cost: number;
|
||||
cost_variance: number;
|
||||
average_fulfillment_rate?: number;
|
||||
average_on_time_delivery?: number;
|
||||
top_suppliers: Record<string, any>[];
|
||||
critical_items: Record<string, any>[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Procurement dashboard data
|
||||
* Backend: GET /api/v1/tenants/{tenant_id}/procurement/analytics/procurement
|
||||
*/
|
||||
export interface ProcurementDashboardData {
|
||||
current_plan?: ProcurementPlanResponse;
|
||||
summary: {
|
||||
total_plans: number;
|
||||
total_estimated_cost: number;
|
||||
total_approved_cost: number;
|
||||
cost_variance: number;
|
||||
};
|
||||
upcoming_deliveries?: Record<string, any>[];
|
||||
overdue_requirements?: Record<string, any>[];
|
||||
low_stock_alerts?: Record<string, any>[];
|
||||
performance_metrics: {
|
||||
average_fulfillment_rate: number;
|
||||
average_on_time_delivery: number;
|
||||
cost_accuracy: number;
|
||||
supplier_performance: number;
|
||||
fulfillment_trend?: number;
|
||||
on_time_trend?: number;
|
||||
cost_variance_trend?: number;
|
||||
};
|
||||
|
||||
plan_status_distribution?: Array<{
|
||||
status: string;
|
||||
count: number;
|
||||
}>;
|
||||
critical_requirements?: {
|
||||
low_stock: number;
|
||||
overdue: number;
|
||||
high_priority: number;
|
||||
};
|
||||
recent_plans?: Array<{
|
||||
id: string;
|
||||
plan_number: string;
|
||||
plan_date: string;
|
||||
status: string;
|
||||
total_requirements: number;
|
||||
total_estimated_cost: number;
|
||||
created_at: string;
|
||||
}>;
|
||||
supplier_performance?: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
total_orders: number;
|
||||
fulfillment_rate: number;
|
||||
on_time_rate: number;
|
||||
quality_score: number;
|
||||
}>;
|
||||
cost_by_category?: Array<{
|
||||
name: string;
|
||||
amount: number;
|
||||
}>;
|
||||
quality_metrics?: {
|
||||
avg_score: number;
|
||||
high_quality_count: number;
|
||||
low_quality_count: number;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Procurement trends data for time-series charts
|
||||
* Backend: GET /api/v1/tenants/{tenant_id}/procurement/analytics/procurement/trends
|
||||
*/
|
||||
export interface ProcurementTrendsData {
|
||||
performance_trend: Array<{
|
||||
date: string;
|
||||
fulfillment_rate: number;
|
||||
on_time_rate: number;
|
||||
}>;
|
||||
quality_trend: Array<{
|
||||
date: string;
|
||||
quality_score: number;
|
||||
}>;
|
||||
period_days: number;
|
||||
start_date: string;
|
||||
end_date: string;
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// REQUEST & RESPONSE TYPES
|
||||
// ================================================================
|
||||
|
||||
/**
|
||||
* Generate procurement plan request
|
||||
* Backend: POST /api/v1/tenants/{tenant_id}/procurement/plans/generate
|
||||
*/
|
||||
export interface GeneratePlanRequest {
|
||||
plan_date?: string;
|
||||
force_regenerate: boolean;
|
||||
planning_horizon_days: number;
|
||||
include_safety_stock: boolean;
|
||||
safety_stock_percentage: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate procurement plan response
|
||||
* Backend: POST /api/v1/tenants/{tenant_id}/procurement/plans/generate
|
||||
*/
|
||||
export interface GeneratePlanResponse {
|
||||
success: boolean;
|
||||
message: string;
|
||||
plan?: ProcurementPlanResponse;
|
||||
warnings: string[];
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-generate procurement request (Orchestrator integration)
|
||||
* Backend: POST /api/v1/tenants/{tenant_id}/procurement/auto-generate
|
||||
*/
|
||||
export interface AutoGenerateProcurementRequest {
|
||||
forecast_data: Record<string, any>;
|
||||
production_schedule_id?: string;
|
||||
target_date?: string;
|
||||
planning_horizon_days: number;
|
||||
safety_stock_percentage: number;
|
||||
auto_create_pos: boolean;
|
||||
auto_approve_pos: boolean;
|
||||
|
||||
// Cached data from Orchestrator
|
||||
inventory_data?: Record<string, any>;
|
||||
suppliers_data?: Record<string, any>;
|
||||
recipes_data?: Record<string, any>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-generate procurement response
|
||||
* Backend: POST /api/v1/tenants/{tenant_id}/procurement/auto-generate
|
||||
*/
|
||||
export interface AutoGenerateProcurementResponse {
|
||||
success: boolean;
|
||||
message: string;
|
||||
plan_id?: string;
|
||||
plan_number?: string;
|
||||
requirements_created: number;
|
||||
purchase_orders_created: number;
|
||||
purchase_orders_auto_approved: number;
|
||||
total_estimated_cost: number;
|
||||
warnings: string[];
|
||||
errors: string[];
|
||||
created_pos: Array<{
|
||||
po_id: string;
|
||||
po_number: string;
|
||||
supplier_id: string;
|
||||
items_count: number;
|
||||
total_amount: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create purchase orders from plan result
|
||||
* Backend: POST /api/v1/tenants/{tenant_id}/procurement/plans/{plan_id}/create-purchase-orders
|
||||
*/
|
||||
export interface CreatePOsResult {
|
||||
success: boolean;
|
||||
created_pos: {
|
||||
po_id: string;
|
||||
po_number: string;
|
||||
supplier_id: string;
|
||||
items_count: number;
|
||||
total_amount: number;
|
||||
}[];
|
||||
failed_pos: {
|
||||
supplier_id: string;
|
||||
error: string;
|
||||
}[];
|
||||
total_created: number;
|
||||
total_failed: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Link requirement to purchase order request
|
||||
* Backend: Used in requirement linking operations
|
||||
*/
|
||||
export interface LinkRequirementToPORequest {
|
||||
purchase_order_id: string;
|
||||
purchase_order_number: string;
|
||||
ordered_quantity: number;
|
||||
expected_delivery_date?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update delivery status request
|
||||
* Backend: Used in delivery status updates
|
||||
*/
|
||||
export interface UpdateDeliveryStatusRequest {
|
||||
delivery_status: string;
|
||||
received_quantity?: number;
|
||||
actual_delivery_date?: string;
|
||||
quality_rating?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Approval request
|
||||
* Backend: Used in plan approval operations
|
||||
*/
|
||||
export interface ApprovalRequest {
|
||||
approval_notes?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rejection request
|
||||
* Backend: Used in plan rejection operations
|
||||
*/
|
||||
export interface RejectionRequest {
|
||||
rejection_notes?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Paginated procurement plans
|
||||
* Backend: GET /api/v1/tenants/{tenant_id}/procurement/plans
|
||||
*/
|
||||
export interface PaginatedProcurementPlans {
|
||||
plans: ProcurementPlanResponse[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
has_more: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Forecast request
|
||||
* Backend: Used in forecasting operations
|
||||
*/
|
||||
export interface ForecastRequest {
|
||||
target_date: string;
|
||||
horizon_days: number;
|
||||
include_confidence_intervals: boolean;
|
||||
product_ids?: string[];
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// QUERY PARAMETER TYPES
|
||||
// ================================================================
|
||||
|
||||
/**
|
||||
* Get procurement plans query parameters
|
||||
* Backend: GET /api/v1/tenants/{tenant_id}/procurement/plans
|
||||
*/
|
||||
export interface GetProcurementPlansParams {
|
||||
tenant_id: string;
|
||||
status?: string;
|
||||
start_date?: string;
|
||||
end_date?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get plan requirements query parameters
|
||||
* Backend: GET /api/v1/tenants/{tenant_id}/procurement/plans/{plan_id}/requirements
|
||||
*/
|
||||
export interface GetPlanRequirementsParams {
|
||||
tenant_id: string;
|
||||
plan_id: string;
|
||||
status?: string;
|
||||
priority?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update plan status parameters
|
||||
* Backend: PATCH /api/v1/tenants/{tenant_id}/procurement/plans/{plan_id}/status
|
||||
*/
|
||||
export interface UpdatePlanStatusParams {
|
||||
tenant_id: string;
|
||||
plan_id: string;
|
||||
status: PlanStatus;
|
||||
notes?: string;
|
||||
}
|
||||
375
frontend/src/components/domain/team/TransferOwnershipModal.tsx
Normal file
375
frontend/src/components/domain/team/TransferOwnershipModal.tsx
Normal file
@@ -0,0 +1,375 @@
|
||||
/**
|
||||
* Transfer Ownership Modal Component
|
||||
*
|
||||
* Allows the current tenant owner to transfer ownership to another admin.
|
||||
* This is a critical operation with multiple confirmation steps.
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { Crown, AlertTriangle, Shield, ChevronRight } from 'lucide-react';
|
||||
import { StatusModal } from '../../ui';
|
||||
import { TENANT_ROLES } from '../../../types/roles';
|
||||
|
||||
export interface TeamMember {
|
||||
id: string;
|
||||
user_id: string;
|
||||
role: string;
|
||||
is_active: boolean;
|
||||
user_email?: string | null;
|
||||
user_full_name?: string | null;
|
||||
joined_at?: string | null;
|
||||
}
|
||||
|
||||
interface TransferOwnershipModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onTransfer: (newOwnerId: string) => Promise<void>;
|
||||
currentOwner: TeamMember | null;
|
||||
eligibleMembers: TeamMember[]; // Only admins should be eligible
|
||||
tenantName: string;
|
||||
}
|
||||
|
||||
const TransferOwnershipModal: React.FC<TransferOwnershipModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onTransfer,
|
||||
currentOwner,
|
||||
eligibleMembers,
|
||||
tenantName,
|
||||
}) => {
|
||||
const [selectedMemberId, setSelectedMemberId] = useState<string>('');
|
||||
const [confirmationStep, setConfirmationStep] = useState<1 | 2>(1);
|
||||
const [confirmationText, setConfirmationText] = useState('');
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const selectedMember = eligibleMembers.find(m => m.user_id === selectedMemberId);
|
||||
const requiredConfirmationText = 'TRANSFERIR PROPIEDAD';
|
||||
|
||||
const handleReset = () => {
|
||||
setSelectedMemberId('');
|
||||
setConfirmationStep(1);
|
||||
setConfirmationText('');
|
||||
setError(null);
|
||||
setIsProcessing(false);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
handleReset();
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleNextStep = () => {
|
||||
if (!selectedMember) {
|
||||
setError('Por favor selecciona un miembro');
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
setConfirmationStep(2);
|
||||
};
|
||||
|
||||
const handleTransfer = async () => {
|
||||
if (confirmationText !== requiredConfirmationText) {
|
||||
setError(`Por favor escribe "${requiredConfirmationText}" para confirmar`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!selectedMember) {
|
||||
setError('No se ha seleccionado ningún miembro');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsProcessing(true);
|
||||
setError(null);
|
||||
await onTransfer(selectedMember.user_id);
|
||||
handleClose();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Error al transferir la propiedad');
|
||||
setIsProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<StatusModal
|
||||
isOpen={isOpen}
|
||||
onClose={handleClose}
|
||||
title="Transferir Propiedad"
|
||||
subtitle={`Organización: ${tenantName}`}
|
||||
size="lg"
|
||||
preventClose={isProcessing}
|
||||
>
|
||||
<div className="space-y-6">
|
||||
{/* Warning Banner */}
|
||||
<div className="bg-color-warning bg-opacity-10 border border-color-warning rounded-lg p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertTriangle className="w-5 h-5 text-color-warning flex-shrink-0 mt-0.5" />
|
||||
<div className="flex-1">
|
||||
<h4 className="font-semibold text-text-primary mb-1">
|
||||
Acción Irreversible
|
||||
</h4>
|
||||
<p className="text-sm text-text-secondary">
|
||||
Transferir la propiedad es permanente. El nuevo propietario tendrá control total
|
||||
de la organización y podrá cambiar todos los permisos, incluyendo los tuyos.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Step 1: Select New Owner */}
|
||||
{confirmationStep === 1 && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-text-primary mb-2">
|
||||
Paso 1: Selecciona el Nuevo Propietario
|
||||
</h3>
|
||||
<p className="text-sm text-text-secondary mb-4">
|
||||
Solo puedes transferir la propiedad a un administrador actual de la organización.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Current Owner Info */}
|
||||
<div className="bg-bg-tertiary rounded-lg p-4">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<Crown className="w-5 h-5 text-color-primary" />
|
||||
<span className="text-sm font-medium text-text-secondary">
|
||||
Propietario Actual
|
||||
</span>
|
||||
</div>
|
||||
<div className="ml-8">
|
||||
<p className="font-medium text-text-primary">
|
||||
{currentOwner?.user_full_name || currentOwner?.user_email || 'Usuario actual'}
|
||||
</p>
|
||||
<p className="text-sm text-text-secondary">
|
||||
{currentOwner?.user_email}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Eligible Members Selection */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-3">
|
||||
Selecciona el nuevo propietario:
|
||||
</label>
|
||||
{eligibleMembers.length === 0 ? (
|
||||
<div className="bg-bg-tertiary rounded-lg p-4 text-center">
|
||||
<Shield className="w-12 h-12 text-text-tertiary mx-auto mb-2" />
|
||||
<p className="text-sm text-text-secondary">
|
||||
No hay administradores disponibles para la transferencia.
|
||||
</p>
|
||||
<p className="text-xs text-text-tertiary mt-1">
|
||||
Primero promociona a un miembro a administrador.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{eligibleMembers.map((member) => (
|
||||
<button
|
||||
key={member.user_id}
|
||||
onClick={() => {
|
||||
setSelectedMemberId(member.user_id);
|
||||
setError(null);
|
||||
}}
|
||||
className={`
|
||||
w-full text-left p-4 rounded-lg border-2 transition-all
|
||||
${selectedMemberId === member.user_id
|
||||
? 'border-color-primary bg-color-primary bg-opacity-5'
|
||||
: 'border-border-primary hover:border-border-hover bg-bg-secondary'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3 flex-1">
|
||||
<div className={`
|
||||
w-10 h-10 rounded-full flex items-center justify-center
|
||||
${selectedMemberId === member.user_id
|
||||
? 'bg-color-primary text-text-inverse'
|
||||
: 'bg-bg-tertiary text-text-secondary'
|
||||
}
|
||||
`}>
|
||||
<Shield className="w-5 h-5" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="font-medium text-text-primary">
|
||||
{member.user_full_name || member.user_email}
|
||||
</p>
|
||||
<p className="text-sm text-text-secondary">
|
||||
{member.user_email}
|
||||
</p>
|
||||
<p className="text-xs text-text-tertiary mt-1">
|
||||
Rol actual: Administrador
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{selectedMemberId === member.user_id && (
|
||||
<div className="w-6 h-6 rounded-full bg-color-primary flex items-center justify-center">
|
||||
<svg className="w-4 h-4 text-text-inverse" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-color-error bg-opacity-10 border border-color-error rounded-lg p-3">
|
||||
<p className="text-sm text-color-error">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-end gap-3 pt-4 border-t border-border-primary">
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="btn btn-outline"
|
||||
disabled={isProcessing}
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
onClick={handleNextStep}
|
||||
disabled={!selectedMember || eligibleMembers.length === 0 || isProcessing}
|
||||
className="btn btn-primary flex items-center gap-2"
|
||||
>
|
||||
Continuar
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 2: Confirmation */}
|
||||
{confirmationStep === 2 && selectedMember && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-text-primary mb-2">
|
||||
Paso 2: Confirma la Transferencia
|
||||
</h3>
|
||||
<p className="text-sm text-text-secondary mb-4">
|
||||
Por favor revisa cuidadosamente antes de confirmar.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Transfer Summary */}
|
||||
<div className="bg-bg-tertiary rounded-lg p-4 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Crown className="w-5 h-5 text-text-secondary" />
|
||||
<div>
|
||||
<p className="text-xs text-text-tertiary">De</p>
|
||||
<p className="font-medium text-text-primary">
|
||||
{currentOwner?.user_full_name || currentOwner?.user_email}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<ChevronRight className="w-5 h-5 text-text-tertiary" />
|
||||
<div className="flex items-center gap-3">
|
||||
<Crown className="w-5 h-5 text-color-primary" />
|
||||
<div>
|
||||
<p className="text-xs text-text-tertiary">A</p>
|
||||
<p className="font-medium text-text-primary">
|
||||
{selectedMember.user_full_name || selectedMember.user_email}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Consequences List */}
|
||||
<div className="bg-color-warning bg-opacity-5 rounded-lg p-4">
|
||||
<h4 className="font-semibold text-text-primary mb-3">
|
||||
Qué sucederá:
|
||||
</h4>
|
||||
<ul className="space-y-2 text-sm text-text-secondary">
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="text-color-warning mt-0.5">•</span>
|
||||
<span>
|
||||
<strong>{selectedMember.user_full_name || selectedMember.user_email}</strong> será
|
||||
el nuevo propietario con control total
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="text-color-warning mt-0.5">•</span>
|
||||
<span>Tu rol cambiará a <strong>Administrador</strong></span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="text-color-warning mt-0.5">•</span>
|
||||
<span>El nuevo propietario podrá modificar tu rol o remover tu acceso</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="text-color-warning mt-0.5">•</span>
|
||||
<span>Esta acción <strong>no se puede deshacer</strong></span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Confirmation Input */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-2">
|
||||
Escribe <code className="bg-bg-tertiary px-2 py-1 rounded text-color-error font-mono text-xs">
|
||||
{requiredConfirmationText}
|
||||
</code> para confirmar:
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={confirmationText}
|
||||
onChange={(e) => {
|
||||
setConfirmationText(e.target.value);
|
||||
setError(null);
|
||||
}}
|
||||
placeholder={requiredConfirmationText}
|
||||
className="input w-full font-mono"
|
||||
disabled={isProcessing}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-color-error bg-opacity-10 border border-color-error rounded-lg p-3">
|
||||
<p className="text-sm text-color-error">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-end gap-3 pt-4 border-t border-border-primary">
|
||||
<button
|
||||
onClick={() => {
|
||||
setConfirmationStep(1);
|
||||
setConfirmationText('');
|
||||
setError(null);
|
||||
}}
|
||||
className="btn btn-outline"
|
||||
disabled={isProcessing}
|
||||
>
|
||||
Atrás
|
||||
</button>
|
||||
<button
|
||||
onClick={handleTransfer}
|
||||
disabled={confirmationText !== requiredConfirmationText || isProcessing}
|
||||
className="btn btn-error flex items-center gap-2"
|
||||
>
|
||||
{isProcessing ? (
|
||||
<>
|
||||
<div className="spinner spinner-sm"></div>
|
||||
Transfiriendo...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Crown className="w-4 h-4" />
|
||||
Transferir Propiedad
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</StatusModal>
|
||||
);
|
||||
};
|
||||
|
||||
export default TransferOwnershipModal;
|
||||
@@ -20,6 +20,7 @@ export { StatsCard, StatsGrid } from './Stats';
|
||||
export { StatusCard, getStatusColor } from './StatusCard';
|
||||
export { EditViewModal } from './EditViewModal';
|
||||
export { AddModal } from './AddModal';
|
||||
export { StatusModal } from './StatusModal';
|
||||
export { DialogModal, showInfoDialog, showWarningDialog, showErrorDialog, showSuccessDialog, showConfirmDialog } from './DialogModal';
|
||||
export { TenantSwitcher } from './TenantSwitcher';
|
||||
export { LanguageSelector, CompactLanguageSelector } from './LanguageSelector';
|
||||
@@ -51,6 +52,7 @@ export type { StatsCardProps, StatsCardVariant, StatsCardSize, StatsGridProps }
|
||||
export type { StatusCardProps, StatusIndicatorConfig } from './StatusCard';
|
||||
export type { EditViewModalProps, EditViewModalField, EditViewModalSection, EditViewModalAction } from './EditViewModal';
|
||||
export type { AddModalProps, AddModalField, AddModalSection } from './AddModal';
|
||||
export type { StatusModalProps, StatusModalField, StatusModalSection, StatusModalAction } from './StatusModal/StatusModal';
|
||||
export type { DialogModalProps, DialogModalAction } from './DialogModal';
|
||||
export type { LoadingSpinnerProps } from './LoadingSpinner';
|
||||
export type { EmptyStateProps } from './EmptyState';
|
||||
|
||||
@@ -10,35 +10,27 @@ const SubscriptionEventsContext = createContext<SubscriptionEventsContextType |
|
||||
|
||||
export const SubscriptionEventsProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const [subscriptionVersion, setSubscriptionVersion] = useState(0);
|
||||
const [subscribers, setSubscribers] = useState<Set<() => void>>(new Set());
|
||||
const subscribersRef = React.useRef<Set<() => void>>(new Set());
|
||||
|
||||
const notifySubscriptionChanged = useCallback(() => {
|
||||
setSubscriptionVersion(prev => prev + 1);
|
||||
|
||||
// Notify all subscribers
|
||||
subscribers.forEach(callback => {
|
||||
subscribersRef.current.forEach(callback => {
|
||||
try {
|
||||
callback();
|
||||
} catch (error) {
|
||||
console.warn('Error notifying subscription change subscriber:', error);
|
||||
}
|
||||
});
|
||||
}, [subscribers]);
|
||||
}, []); // Empty dependency array - function is now stable
|
||||
|
||||
const subscribeToChanges = useCallback((callback: () => void) => {
|
||||
setSubscribers(prev => {
|
||||
const newSubscribers = new Set(prev);
|
||||
newSubscribers.add(callback);
|
||||
return newSubscribers;
|
||||
});
|
||||
subscribersRef.current.add(callback);
|
||||
|
||||
// Return unsubscribe function
|
||||
return () => {
|
||||
setSubscribers(prev => {
|
||||
const newSubscribers = new Set(prev);
|
||||
newSubscribers.delete(callback);
|
||||
return newSubscribers;
|
||||
});
|
||||
subscribersRef.current.delete(callback);
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ export const getDemoTourSteps = (): DriveStep[] => [
|
||||
{
|
||||
element: '[data-tour="demo-banner"]',
|
||||
popover: {
|
||||
title: '¡Bienvenido a BakeryIA!',
|
||||
title: '¡Bienvenido a El Panadero Digital!',
|
||||
description: 'Descubre cómo gestionar tu panadería en 5 minutos al día. Esta demo de 30 minutos usa datos reales de una panadería española. Te mostramos cómo ahorrar 2-3 horas diarias en planificación y reducir desperdicio un 15-25%. Puedes cerrar el tour con ESC.',
|
||||
side: 'bottom',
|
||||
align: 'center',
|
||||
@@ -105,7 +105,7 @@ export const getMobileTourSteps = (): DriveStep[] => [
|
||||
{
|
||||
element: '[data-tour="demo-banner"]',
|
||||
popover: {
|
||||
title: '¡Bienvenido a BakeryIA!',
|
||||
title: '¡Bienvenido a El Panadero Digital !',
|
||||
description: 'Gestiona tu panadería en 5 min/día. Demo de 30 min con datos reales. Ahorra 2-3h diarias y reduce desperdicio 15-25%.',
|
||||
side: 'bottom',
|
||||
align: 'center',
|
||||
|
||||
@@ -244,13 +244,13 @@
|
||||
}
|
||||
},
|
||||
"central_workshop": {
|
||||
"title": "Obrador Central + Puntos de Venta",
|
||||
"subtitle": "Producción centralizada, distribución múltiple",
|
||||
"description": "Produces centralmente y distribuyes a múltiples puntos de venta. Necesitas coordinar producción, logística y demanda entre ubicaciones para optimizar cada punto.",
|
||||
"title": "Panadería Franquiciada",
|
||||
"subtitle": "Punto de venta con obrador central",
|
||||
"description": "Operas un punto de venta que recibe productos de un obrador central. Necesitas gestionar pedidos, inventario y ventas para optimizar tu operación retail.",
|
||||
"features": {
|
||||
"prediction": "<strong>Predicción agregada y por punto de venta</strong> individual",
|
||||
"distribution": "<strong>Gestión de distribución</strong> multi-ubicación coordinada",
|
||||
"visibility": "<strong>Visibilidad centralizada</strong> con control granular"
|
||||
"prediction": "<strong>Gestión de pedidos</strong> al obrador central",
|
||||
"distribution": "<strong>Control de inventario</strong> de productos recibidos",
|
||||
"visibility": "<strong>Previsión de ventas</strong> para tu punto"
|
||||
}
|
||||
},
|
||||
"same_ai": "La misma IA potente, adaptada a tu forma de trabajar"
|
||||
|
||||
@@ -12,11 +12,21 @@ import {
|
||||
Truck,
|
||||
Calendar
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
LineChart,
|
||||
Line,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
Legend
|
||||
} from 'recharts';
|
||||
import { PageHeader } from '../../../components/layout';
|
||||
import { Card, StatsGrid, Button, Tabs } from '../../../components/ui';
|
||||
import { useSubscription } from '../../../api/hooks/subscription';
|
||||
import { useCurrentTenant } from '../../../stores/tenant.store';
|
||||
import { useProcurementDashboard } from '../../../api/hooks/orders';
|
||||
import { useProcurementDashboard, useProcurementTrends } from '../../../api/hooks/procurement';
|
||||
import { formatters } from '../../../components/ui/Stats/StatsPresets';
|
||||
|
||||
const ProcurementAnalyticsPage: React.FC = () => {
|
||||
@@ -27,6 +37,7 @@ const ProcurementAnalyticsPage: React.FC = () => {
|
||||
const [activeTab, setActiveTab] = useState('overview');
|
||||
|
||||
const { data: dashboard, isLoading: dashboardLoading } = useProcurementDashboard(tenantId);
|
||||
const { data: trends, isLoading: trendsLoading } = useProcurementTrends(tenantId, 7);
|
||||
|
||||
// Check if user has access to advanced analytics (professional/enterprise)
|
||||
const hasAdvancedAccess = canAccessAnalytics('advanced');
|
||||
@@ -302,15 +313,63 @@ const ProcurementAnalyticsPage: React.FC = () => {
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Performance Trend Chart Placeholder */}
|
||||
{/* Performance Trend Chart */}
|
||||
<Card>
|
||||
<div className="p-6">
|
||||
<h3 className="text-lg font-medium text-[var(--text-primary)] mb-4">
|
||||
Tendencias de Rendimiento
|
||||
Tendencias de Rendimiento (Últimos 7 días)
|
||||
</h3>
|
||||
<div className="h-64 flex items-center justify-center text-[var(--text-tertiary)]">
|
||||
Gráfico de tendencias - Próximamente
|
||||
{trendsLoading ? (
|
||||
<div className="h-64 flex items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-[var(--color-primary)]"></div>
|
||||
</div>
|
||||
) : trends && trends.performance_trend && trends.performance_trend.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<LineChart data={trends.performance_trend}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="var(--border-primary)" />
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
stroke="var(--text-tertiary)"
|
||||
tick={{ fill: 'var(--text-secondary)' }}
|
||||
/>
|
||||
<YAxis
|
||||
stroke="var(--text-tertiary)"
|
||||
tick={{ fill: 'var(--text-secondary)' }}
|
||||
tickFormatter={(value) => `${(value * 100).toFixed(0)}%`}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: 'var(--bg-primary)',
|
||||
border: '1px solid var(--border-primary)',
|
||||
borderRadius: '8px'
|
||||
}}
|
||||
formatter={(value: any) => `${(value * 100).toFixed(1)}%`}
|
||||
labelStyle={{ color: 'var(--text-primary)' }}
|
||||
/>
|
||||
<Legend />
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="fulfillment_rate"
|
||||
stroke="var(--color-success)"
|
||||
strokeWidth={2}
|
||||
name="Tasa de Cumplimiento"
|
||||
dot={{ fill: 'var(--color-success)' }}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="on_time_rate"
|
||||
stroke="var(--color-info)"
|
||||
strokeWidth={2}
|
||||
name="Entregas a Tiempo"
|
||||
dot={{ fill: 'var(--color-info)' }}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<div className="h-64 flex items-center justify-center text-[var(--text-tertiary)]">
|
||||
No hay datos de tendencias disponibles
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</>
|
||||
@@ -459,11 +518,51 @@ const ProcurementAnalyticsPage: React.FC = () => {
|
||||
<Card>
|
||||
<div className="p-6">
|
||||
<h3 className="text-lg font-medium text-[var(--text-primary)] mb-4">
|
||||
Tendencia de Calidad
|
||||
Tendencia de Calidad (Últimos 7 días)
|
||||
</h3>
|
||||
<div className="h-48 flex items-center justify-center text-[var(--text-tertiary)]">
|
||||
Gráfico de tendencia de calidad - Próximamente
|
||||
{trendsLoading ? (
|
||||
<div className="h-48 flex items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-[var(--color-primary)]"></div>
|
||||
</div>
|
||||
) : trends && trends.quality_trend && trends.quality_trend.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<LineChart data={trends.quality_trend}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="var(--border-primary)" />
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
stroke="var(--text-tertiary)"
|
||||
tick={{ fill: 'var(--text-secondary)' }}
|
||||
/>
|
||||
<YAxis
|
||||
stroke="var(--text-tertiary)"
|
||||
tick={{ fill: 'var(--text-secondary)' }}
|
||||
domain={[0, 10]}
|
||||
ticks={[0, 2, 4, 6, 8, 10]}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: 'var(--bg-primary)',
|
||||
border: '1px solid var(--border-primary)',
|
||||
borderRadius: '8px'
|
||||
}}
|
||||
formatter={(value: any) => `${value.toFixed(1)} / 10`}
|
||||
labelStyle={{ color: 'var(--text-primary)' }}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="quality_score"
|
||||
stroke="var(--color-warning)"
|
||||
strokeWidth={2}
|
||||
name="Puntuación de Calidad"
|
||||
dot={{ fill: 'var(--color-warning)' }}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<div className="h-48 flex items-center justify-center text-[var(--text-tertiary)]">
|
||||
No hay datos de calidad disponibles
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -1,22 +1,80 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Activity, Clock, Users, TrendingUp, Target, AlertCircle, Download, Calendar } from 'lucide-react';
|
||||
import { Button, Card, Badge } from '../../../../components/ui';
|
||||
import {
|
||||
Activity,
|
||||
Clock,
|
||||
TrendingUp,
|
||||
Target,
|
||||
AlertCircle,
|
||||
Download,
|
||||
Calendar,
|
||||
Lock,
|
||||
BarChart3,
|
||||
Zap,
|
||||
DollarSign,
|
||||
Package,
|
||||
AlertOctagon,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
ResponsiveContainer,
|
||||
BarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
Legend,
|
||||
LineChart,
|
||||
Line,
|
||||
RadarChart,
|
||||
Radar,
|
||||
PolarGrid,
|
||||
PolarAngleAxis,
|
||||
PolarRadiusAxis,
|
||||
} from 'recharts';
|
||||
import { Button, Card, Badge, StatsGrid, Tabs } from '../../../../components/ui';
|
||||
import { PageHeader } from '../../../../components/layout';
|
||||
import { useSubscription } from '../../../../api/hooks/subscription';
|
||||
import { useCurrentTenant } from '../../../../stores/tenant.store';
|
||||
import {
|
||||
useCycleTimeMetrics,
|
||||
useProcessEfficiencyScore,
|
||||
useResourceUtilization,
|
||||
useCostRevenueRatio,
|
||||
useQualityImpactIndex,
|
||||
useCriticalBottlenecks,
|
||||
useDepartmentPerformance,
|
||||
usePerformanceAlerts,
|
||||
} from '../../../../api/hooks/performance';
|
||||
import { TimePeriod } from '../../../../api/types/performance';
|
||||
|
||||
const PerformanceAnalyticsPage: React.FC = () => {
|
||||
const [selectedTimeframe, setSelectedTimeframe] = useState('month');
|
||||
const [selectedMetric, setSelectedMetric] = useState('efficiency');
|
||||
|
||||
const performanceMetrics = {
|
||||
overallEfficiency: 87.5,
|
||||
productionTime: 4.2,
|
||||
qualityScore: 92.1,
|
||||
employeeProductivity: 89.3,
|
||||
customerSatisfaction: 94.7,
|
||||
resourceUtilization: 78.9,
|
||||
// Formatters for StatsGrid
|
||||
const formatters = {
|
||||
number: (value: number) => value.toFixed(0),
|
||||
percentage: (value: number) => `${value.toFixed(1)}%`,
|
||||
hours: (value: number) => `${value.toFixed(1)}h`,
|
||||
currency: (value: number) => `€${value.toLocaleString('es-ES', { minimumFractionDigits: 0, maximumFractionDigits: 0 })}`,
|
||||
};
|
||||
|
||||
const timeframes = [
|
||||
const PerformanceAnalyticsPage: React.FC = () => {
|
||||
const { canAccessAnalytics, subscriptionInfo } = useSubscription();
|
||||
const currentTenant = useCurrentTenant();
|
||||
const tenantId = currentTenant?.id || '';
|
||||
|
||||
const [selectedPeriod, setSelectedPeriod] = useState<TimePeriod>('week');
|
||||
const [activeTab, setActiveTab] = useState('overview');
|
||||
|
||||
// Fetch all cross-functional performance data
|
||||
const { data: cycleTime, isLoading: cycleTimeLoading } = useCycleTimeMetrics(tenantId, selectedPeriod);
|
||||
const { data: processScore, isLoading: processScoreLoading } = useProcessEfficiencyScore(tenantId, selectedPeriod);
|
||||
const { data: resourceUtil, isLoading: resourceUtilLoading } = useResourceUtilization(tenantId, selectedPeriod);
|
||||
const { data: costRevenue, isLoading: costRevenueLoading } = useCostRevenueRatio(tenantId, selectedPeriod);
|
||||
const { data: qualityIndex, isLoading: qualityIndexLoading } = useQualityImpactIndex(tenantId, selectedPeriod);
|
||||
const { data: bottlenecks, isLoading: bottlenecksLoading } = useCriticalBottlenecks(tenantId, selectedPeriod);
|
||||
const { data: departments, isLoading: departmentsLoading } = useDepartmentPerformance(tenantId, selectedPeriod);
|
||||
const { data: alerts, isLoading: alertsLoading } = usePerformanceAlerts(tenantId);
|
||||
|
||||
// Period options
|
||||
const timeframes: { value: TimePeriod; label: string }[] = [
|
||||
{ value: 'day', label: 'Hoy' },
|
||||
{ value: 'week', label: 'Esta Semana' },
|
||||
{ value: 'month', label: 'Este Mes' },
|
||||
@@ -24,377 +82,565 @@ const PerformanceAnalyticsPage: React.FC = () => {
|
||||
{ value: 'year', label: 'Año' },
|
||||
];
|
||||
|
||||
const departmentPerformance = [
|
||||
{
|
||||
department: 'Producción',
|
||||
efficiency: 91.2,
|
||||
trend: 5.3,
|
||||
issues: 2,
|
||||
employees: 8,
|
||||
metrics: {
|
||||
avgBatchTime: '2.3h',
|
||||
qualityRate: '94%',
|
||||
wastePercentage: '3.1%'
|
||||
}
|
||||
},
|
||||
{
|
||||
department: 'Ventas',
|
||||
efficiency: 88.7,
|
||||
trend: -1.2,
|
||||
issues: 1,
|
||||
employees: 4,
|
||||
metrics: {
|
||||
avgServiceTime: '3.2min',
|
||||
customerWaitTime: '2.1min',
|
||||
salesPerHour: '€127'
|
||||
}
|
||||
},
|
||||
{
|
||||
department: 'Inventario',
|
||||
efficiency: 82.4,
|
||||
trend: 2.8,
|
||||
issues: 3,
|
||||
employees: 2,
|
||||
metrics: {
|
||||
stockAccuracy: '96.7%',
|
||||
turnoverRate: '12.3',
|
||||
wastageRate: '4.2%'
|
||||
}
|
||||
},
|
||||
{
|
||||
department: 'Administración',
|
||||
efficiency: 94.1,
|
||||
trend: 8.1,
|
||||
issues: 0,
|
||||
employees: 3,
|
||||
metrics: {
|
||||
responseTime: '1.2h',
|
||||
taskCompletion: '98%',
|
||||
documentAccuracy: '99.1%'
|
||||
}
|
||||
}
|
||||
// Tab configuration
|
||||
const tabs = [
|
||||
{ id: 'overview', label: 'Vista General' },
|
||||
{ id: 'efficiency', label: 'Eficiencia Operativa' },
|
||||
{ id: 'quality', label: 'Impacto de Calidad' },
|
||||
{ id: 'optimization', label: 'Optimización' },
|
||||
];
|
||||
|
||||
const kpiTrends = [
|
||||
{
|
||||
name: 'Eficiencia General',
|
||||
current: 87.5,
|
||||
target: 90.0,
|
||||
previous: 84.2,
|
||||
unit: '%',
|
||||
color: 'blue'
|
||||
},
|
||||
{
|
||||
name: 'Tiempo de Producción',
|
||||
current: 4.2,
|
||||
target: 4.0,
|
||||
previous: 4.5,
|
||||
unit: 'h',
|
||||
color: 'green',
|
||||
inverse: true
|
||||
},
|
||||
{
|
||||
name: 'Satisfacción Cliente',
|
||||
current: 94.7,
|
||||
target: 95.0,
|
||||
previous: 93.1,
|
||||
unit: '%',
|
||||
color: 'purple'
|
||||
},
|
||||
{
|
||||
name: 'Utilización de Recursos',
|
||||
current: 78.9,
|
||||
target: 85.0,
|
||||
previous: 76.3,
|
||||
unit: '%',
|
||||
color: 'orange'
|
||||
// Combined loading state
|
||||
const isLoading =
|
||||
cycleTimeLoading ||
|
||||
processScoreLoading ||
|
||||
resourceUtilLoading ||
|
||||
costRevenueLoading ||
|
||||
qualityIndexLoading ||
|
||||
bottlenecksLoading ||
|
||||
departmentsLoading ||
|
||||
alertsLoading;
|
||||
|
||||
// Show loading state while subscription data is being fetched
|
||||
if (subscriptionInfo.loading) {
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<PageHeader
|
||||
title="Análisis de Rendimiento"
|
||||
description="Monitorea métricas transversales que muestran cómo los departamentos trabajan juntos"
|
||||
/>
|
||||
<Card className="p-8 text-center">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-[var(--color-primary)]"></div>
|
||||
<p className="text-[var(--text-secondary)]">Cargando información de suscripción...</p>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
];
|
||||
|
||||
const performanceAlerts = [
|
||||
{
|
||||
id: '1',
|
||||
type: 'warning',
|
||||
title: 'Eficiencia de Inventario Baja',
|
||||
description: 'El departamento de inventario está por debajo del objetivo del 85%',
|
||||
value: '82.4%',
|
||||
target: '85%',
|
||||
department: 'Inventario'
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
type: 'info',
|
||||
title: 'Tiempo de Producción Mejorado',
|
||||
description: 'El tiempo promedio de producción ha mejorado este mes',
|
||||
value: '4.2h',
|
||||
target: '4.0h',
|
||||
department: 'Producción'
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
type: 'success',
|
||||
title: 'Administración Supera Objetivos',
|
||||
description: 'El departamento administrativo está funcionando por encima del objetivo',
|
||||
value: '94.1%',
|
||||
target: '90%',
|
||||
department: 'Administración'
|
||||
// If user doesn't have access to advanced analytics, show upgrade message
|
||||
if (!canAccessAnalytics('advanced')) {
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<PageHeader
|
||||
title="Análisis de Rendimiento"
|
||||
description="Monitorea métricas transversales que muestran cómo los departamentos trabajan juntos"
|
||||
/>
|
||||
<Card className="p-8 text-center">
|
||||
<Lock className="mx-auto h-12 w-12 text-[var(--text-tertiary)] mb-4" />
|
||||
<h3 className="text-lg font-medium text-[var(--text-primary)] mb-2">
|
||||
Funcionalidad Exclusiva para Profesionales y Empresas
|
||||
</h3>
|
||||
<p className="text-[var(--text-secondary)] mb-4">
|
||||
El análisis de rendimiento avanzado está disponible solo para planes Professional y Enterprise.
|
||||
Actualiza tu plan para acceder a métricas transversales de rendimiento, análisis de procesos integrados y optimización operativa.
|
||||
</p>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="md"
|
||||
onClick={() => (window.location.hash = '#/app/settings/profile')}
|
||||
>
|
||||
Actualizar Plan
|
||||
</Button>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
];
|
||||
|
||||
const productivityData = [
|
||||
{ hour: '07:00', efficiency: 75, transactions: 12, employees: 3 },
|
||||
{ hour: '08:00', efficiency: 82, transactions: 18, employees: 5 },
|
||||
{ hour: '09:00', efficiency: 89, transactions: 28, employees: 6 },
|
||||
{ hour: '10:00', efficiency: 91, transactions: 32, employees: 7 },
|
||||
{ hour: '11:00', efficiency: 94, transactions: 38, employees: 8 },
|
||||
{ hour: '12:00', efficiency: 96, transactions: 45, employees: 8 },
|
||||
{ hour: '13:00', efficiency: 95, transactions: 42, employees: 8 },
|
||||
{ hour: '14:00', efficiency: 88, transactions: 35, employees: 7 },
|
||||
{ hour: '15:00', efficiency: 85, transactions: 28, employees: 6 },
|
||||
{ hour: '16:00', efficiency: 83, transactions: 25, employees: 5 },
|
||||
{ hour: '17:00', efficiency: 87, transactions: 31, employees: 6 },
|
||||
{ hour: '18:00', efficiency: 90, transactions: 38, employees: 7 },
|
||||
{ hour: '19:00', efficiency: 86, transactions: 29, employees: 5 },
|
||||
{ hour: '20:00', efficiency: 78, transactions: 18, employees: 3 },
|
||||
];
|
||||
|
||||
const getTrendIcon = (trend: number) => {
|
||||
if (trend > 0) {
|
||||
// Helper functions
|
||||
const getTrendIcon = (trend: 'up' | 'down' | 'stable') => {
|
||||
if (trend === 'up') {
|
||||
return <TrendingUp className="w-4 h-4 text-[var(--color-success)]" />;
|
||||
} else {
|
||||
} else if (trend === 'down') {
|
||||
return <TrendingUp className="w-4 h-4 text-[var(--color-error)] transform rotate-180" />;
|
||||
}
|
||||
return <Activity className="w-4 h-4 text-gray-500" />;
|
||||
};
|
||||
|
||||
const getTrendColor = (trend: number) => {
|
||||
return trend >= 0 ? 'text-[var(--color-success)]' : 'text-[var(--color-error)]';
|
||||
};
|
||||
|
||||
const getPerformanceColor = (value: number, target: number, inverse = false) => {
|
||||
const comparison = inverse ? value < target : value >= target;
|
||||
return comparison ? 'text-[var(--color-success)]' : value >= target * 0.9 ? 'text-yellow-600' : 'text-[var(--color-error)]';
|
||||
};
|
||||
|
||||
const getAlertIcon = (type: string) => {
|
||||
const getAlertIcon = (type: 'warning' | 'critical' | 'info') => {
|
||||
switch (type) {
|
||||
case 'critical':
|
||||
return <AlertCircle className="w-5 h-5 text-[var(--color-error)]" />;
|
||||
case 'warning':
|
||||
return <AlertCircle className="w-5 h-5 text-yellow-600" />;
|
||||
case 'success':
|
||||
return <TrendingUp className="w-5 h-5 text-[var(--color-success)]" />;
|
||||
default:
|
||||
return <Activity className="w-5 h-5 text-[var(--color-info)]" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getAlertColor = (type: string) => {
|
||||
const getAlertColor = (type: 'warning' | 'critical' | 'info') => {
|
||||
switch (type) {
|
||||
case 'critical':
|
||||
return 'bg-red-50 border-red-200';
|
||||
case 'warning':
|
||||
return 'bg-yellow-50 border-yellow-200';
|
||||
case 'success':
|
||||
return 'bg-green-50 border-green-200';
|
||||
default:
|
||||
return 'bg-[var(--color-info)]/5 border-[var(--color-info)]/20';
|
||||
}
|
||||
};
|
||||
|
||||
// Prepare StatsGrid data (6 cross-functional metrics) - NO MEMOIZATION to avoid loops
|
||||
const statsData = [
|
||||
{
|
||||
title: 'Tiempo de Ciclo',
|
||||
value: cycleTime?.average_cycle_time || 0,
|
||||
icon: Clock,
|
||||
formatter: formatters.hours,
|
||||
},
|
||||
{
|
||||
title: 'Eficiencia de Procesos',
|
||||
value: processScore?.overall_score || 0,
|
||||
icon: Zap,
|
||||
formatter: formatters.percentage,
|
||||
},
|
||||
{
|
||||
title: 'Utilización de Recursos',
|
||||
value: resourceUtil?.overall_utilization || 0,
|
||||
icon: Package,
|
||||
formatter: formatters.percentage,
|
||||
},
|
||||
{
|
||||
title: 'Ratio Costo-Ingreso',
|
||||
value: costRevenue?.cost_revenue_ratio || 0,
|
||||
icon: DollarSign,
|
||||
formatter: formatters.percentage,
|
||||
},
|
||||
{
|
||||
title: 'Índice de Calidad',
|
||||
value: qualityIndex?.overall_quality_index || 0,
|
||||
icon: Target,
|
||||
formatter: formatters.percentage,
|
||||
},
|
||||
{
|
||||
title: 'Cuellos de Botella Críticos',
|
||||
value: bottlenecks?.critical_count || 0,
|
||||
icon: AlertOctagon,
|
||||
formatter: formatters.number,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Page Header */}
|
||||
<PageHeader
|
||||
title="Análisis de Rendimiento"
|
||||
description="Monitorea la eficiencia operativa y el rendimiento de todos los departamentos"
|
||||
action={
|
||||
<div className="flex space-x-2">
|
||||
<Button variant="outline">
|
||||
<Calendar className="w-4 h-4 mr-2" />
|
||||
Configurar Alertas
|
||||
</Button>
|
||||
<Button variant="outline">
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
Exportar Reporte
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
description="Monitorea métricas transversales que muestran cómo los departamentos trabajan juntos"
|
||||
actions={[
|
||||
{
|
||||
id: 'configure-alerts',
|
||||
label: 'Configurar Alertas',
|
||||
icon: Calendar,
|
||||
onClick: () => {},
|
||||
variant: 'outline',
|
||||
disabled: true,
|
||||
},
|
||||
{
|
||||
id: 'export-report',
|
||||
label: 'Exportar Reporte',
|
||||
icon: Download,
|
||||
onClick: () => {},
|
||||
variant: 'outline',
|
||||
disabled: true,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* Controls */}
|
||||
<Card className="p-6">
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">Período</label>
|
||||
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">
|
||||
Período
|
||||
</label>
|
||||
<select
|
||||
value={selectedTimeframe}
|
||||
onChange={(e) => setSelectedTimeframe(e.target.value)}
|
||||
className="px-3 py-2 border border-[var(--border-secondary)] rounded-md"
|
||||
value={selectedPeriod}
|
||||
onChange={(e) => setSelectedPeriod(e.target.value as TimePeriod)}
|
||||
className="px-3 py-2 border border-[var(--border-secondary)] rounded-md bg-white"
|
||||
>
|
||||
{timeframes.map(timeframe => (
|
||||
<option key={timeframe.value} value={timeframe.value}>{timeframe.label}</option>
|
||||
{timeframes.map((timeframe) => (
|
||||
<option key={timeframe.value} value={timeframe.value}>
|
||||
{timeframe.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">Métrica Principal</label>
|
||||
<select
|
||||
value={selectedMetric}
|
||||
onChange={(e) => setSelectedMetric(e.target.value)}
|
||||
className="px-3 py-2 border border-[var(--border-secondary)] rounded-md"
|
||||
>
|
||||
<option value="efficiency">Eficiencia</option>
|
||||
<option value="productivity">Productividad</option>
|
||||
<option value="quality">Calidad</option>
|
||||
<option value="satisfaction">Satisfacción</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* KPI Overview */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
{kpiTrends.map((kpi) => (
|
||||
<Card key={kpi.name} className="p-6">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h3 className="text-sm font-medium text-[var(--text-secondary)]">{kpi.name}</h3>
|
||||
<div className={`w-3 h-3 rounded-full bg-${kpi.color}-500`}></div>
|
||||
</div>
|
||||
<div className="flex items-end justify-between">
|
||||
<div>
|
||||
<p className={`text-2xl font-bold ${getPerformanceColor(kpi.current, kpi.target, kpi.inverse)}`}>
|
||||
{kpi.current}{kpi.unit}
|
||||
</p>
|
||||
<p className="text-xs text-[var(--text-tertiary)]">
|
||||
Objetivo: {kpi.target}{kpi.unit}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="flex items-center">
|
||||
{getTrendIcon(kpi.current - kpi.previous)}
|
||||
<span className={`text-sm ml-1 ${getTrendColor(kpi.current - kpi.previous)}`}>
|
||||
{Math.abs(kpi.current - kpi.previous).toFixed(1)}{kpi.unit}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 w-full bg-[var(--bg-quaternary)] rounded-full h-2">
|
||||
<div
|
||||
className={`bg-${kpi.color}-500 h-2 rounded-full transition-all duration-300`}
|
||||
style={{ width: `${Math.min((kpi.current / kpi.target) * 100, 100)}%` }}
|
||||
></div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
{/* Block 1: StatsGrid with 6 cross-functional metrics */}
|
||||
<StatsGrid stats={statsData} loading={isLoading} />
|
||||
|
||||
{/* Performance Alerts */}
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-4">Alertas de Rendimiento</h3>
|
||||
<div className="space-y-3">
|
||||
{performanceAlerts.map((alert) => (
|
||||
<div key={alert.id} className={`p-4 rounded-lg border ${getAlertColor(alert.type)}`}>
|
||||
<div className="flex items-start space-x-3">
|
||||
{getAlertIcon(alert.type)}
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="text-sm font-medium text-[var(--text-primary)]">{alert.title}</h4>
|
||||
<Badge variant="gray">{alert.department}</Badge>
|
||||
</div>
|
||||
<p className="text-sm text-[var(--text-secondary)] mt-1">{alert.description}</p>
|
||||
<div className="flex items-center space-x-4 mt-2">
|
||||
<span className="text-sm">
|
||||
<strong>Actual:</strong> {alert.value}
|
||||
</span>
|
||||
<span className="text-sm">
|
||||
<strong>Objetivo:</strong> {alert.target}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
{/* Block 2: Tabs */}
|
||||
<Tabs items={tabs} activeTab={activeTab} onTabChange={setActiveTab} />
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Department Performance */}
|
||||
{/* Block 3: Tab Content */}
|
||||
<div className="space-y-6">
|
||||
{/* Vista General Tab */}
|
||||
{activeTab === 'overview' && !isLoading && (
|
||||
<>
|
||||
{/* Department Comparison Matrix */}
|
||||
{departments && departments.length > 0 && (
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-4">Rendimiento por Departamento</h3>
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-4">
|
||||
Comparación de Departamentos
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
{departmentPerformance.map((dept) => (
|
||||
<div key={dept.department} className="border rounded-lg p-4">
|
||||
{departments.map((dept) => (
|
||||
<div key={dept.department_id} className="border rounded-lg p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center space-x-3">
|
||||
<h4 className="font-medium text-[var(--text-primary)]">{dept.department}</h4>
|
||||
<Badge variant="gray">{dept.employees} empleados</Badge>
|
||||
</div>
|
||||
<h4 className="font-medium text-[var(--text-primary)]">
|
||||
{dept.department_name}
|
||||
</h4>
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="text-lg font-semibold text-[var(--text-primary)]">
|
||||
{dept.efficiency}%
|
||||
{dept.efficiency.toFixed(1)}%
|
||||
</span>
|
||||
<div className="flex items-center">
|
||||
{getTrendIcon(dept.trend)}
|
||||
<span className={`text-sm ml-1 ${getTrendColor(dept.trend)}`}>
|
||||
{Math.abs(dept.trend).toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-4 text-sm">
|
||||
{Object.entries(dept.metrics).map(([key, value]) => (
|
||||
<div key={key}>
|
||||
<div>
|
||||
<p className="text-[var(--text-tertiary)] text-xs">
|
||||
{key.replace(/([A-Z])/g, ' $1').replace(/^./, str => str.toUpperCase())}
|
||||
{dept.metrics.primary_metric.label}
|
||||
</p>
|
||||
<p className="font-medium">{value}</p>
|
||||
<p className="font-medium">
|
||||
{dept.metrics.primary_metric.value.toFixed(1)}
|
||||
{dept.metrics.primary_metric.unit}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[var(--text-tertiary)] text-xs">
|
||||
{dept.metrics.secondary_metric.label}
|
||||
</p>
|
||||
<p className="font-medium">
|
||||
{dept.metrics.secondary_metric.value.toFixed(1)}
|
||||
{dept.metrics.secondary_metric.unit}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[var(--text-tertiary)] text-xs">
|
||||
{dept.metrics.tertiary_metric.label}
|
||||
</p>
|
||||
<p className="font-medium">
|
||||
{dept.metrics.tertiary_metric.value.toFixed(1)}
|
||||
{dept.metrics.tertiary_metric.unit}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{dept.issues > 0 && (
|
||||
<div className="mt-3 flex items-center text-sm text-[var(--color-warning)]">
|
||||
<AlertCircle className="w-4 h-4 mr-1" />
|
||||
{dept.issues} problema{dept.issues > 1 ? 's' : ''} detectado{dept.issues > 1 ? 's' : ''}
|
||||
{/* Process Efficiency Breakdown */}
|
||||
{processScore && (
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-4">
|
||||
Desglose de Eficiencia por Procesos
|
||||
</h3>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<BarChart
|
||||
data={[
|
||||
{ name: 'Producción', value: processScore.production_efficiency, weight: processScore.breakdown.production.weight },
|
||||
{ name: 'Inventario', value: processScore.inventory_efficiency, weight: processScore.breakdown.inventory.weight },
|
||||
{ name: 'Compras', value: processScore.procurement_efficiency, weight: processScore.breakdown.procurement.weight },
|
||||
{ name: 'Pedidos', value: processScore.order_efficiency, weight: processScore.breakdown.orders.weight },
|
||||
]}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="name" />
|
||||
<YAxis />
|
||||
<Tooltip />
|
||||
<Legend />
|
||||
<Bar dataKey="value" fill="var(--color-primary)" name="Eficiencia (%)" />
|
||||
<Bar dataKey="weight" fill="var(--color-secondary)" name="Peso (%)" />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</Card>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Eficiencia Operativa Tab */}
|
||||
{activeTab === 'efficiency' && !isLoading && (
|
||||
<>
|
||||
{/* Cycle Time Breakdown */}
|
||||
{cycleTime && (
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-4">
|
||||
Análisis de Tiempo de Ciclo
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-6">
|
||||
<div className="text-center p-4 bg-[var(--bg-secondary)] rounded-lg">
|
||||
<p className="text-sm text-[var(--text-secondary)] mb-2">Pedido → Producción</p>
|
||||
<p className="text-2xl font-bold text-[var(--text-primary)]">
|
||||
{cycleTime.order_to_production_time.toFixed(1)}h
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-center p-4 bg-[var(--bg-secondary)] rounded-lg">
|
||||
<p className="text-sm text-[var(--text-secondary)] mb-2">Tiempo de Producción</p>
|
||||
<p className="text-2xl font-bold text-[var(--text-primary)]">
|
||||
{cycleTime.production_time.toFixed(1)}h
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-center p-4 bg-[var(--bg-secondary)] rounded-lg">
|
||||
<p className="text-sm text-[var(--text-secondary)] mb-2">Producción → Entrega</p>
|
||||
<p className="text-2xl font-bold text-[var(--text-primary)]">
|
||||
{cycleTime.production_to_delivery_time.toFixed(1)}h
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between p-4 bg-blue-50 rounded-lg">
|
||||
<span className="text-sm font-medium">Tiempo Total de Ciclo</span>
|
||||
<span className="text-xl font-bold text-[var(--color-primary)]">
|
||||
{cycleTime.average_cycle_time.toFixed(1)}h
|
||||
</span>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Bottlenecks Analysis */}
|
||||
{bottlenecks && bottlenecks.bottlenecks.length > 0 && (
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-4">
|
||||
Cuellos de Botella Detectados
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
{bottlenecks.bottlenecks.map((bottleneck, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`p-4 rounded-lg border ${
|
||||
bottleneck.severity === 'high'
|
||||
? 'bg-red-50 border-red-200'
|
||||
: 'bg-yellow-50 border-yellow-200'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start space-x-3">
|
||||
<AlertCircle
|
||||
className={`w-5 h-5 ${
|
||||
bottleneck.severity === 'high' ? 'text-red-600' : 'text-yellow-600'
|
||||
}`}
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="text-sm font-medium text-[var(--text-primary)]">
|
||||
{bottleneck.description}
|
||||
</h4>
|
||||
<Badge variant={bottleneck.severity === 'high' ? 'destructive' : 'default'}>
|
||||
{bottleneck.area}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-sm text-[var(--text-secondary)] mt-1">
|
||||
{bottleneck.metric}: {bottleneck.value.toFixed(1)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Resource Utilization */}
|
||||
{resourceUtil && (
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-4">
|
||||
Utilización de Recursos
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div className="text-center p-4 bg-[var(--bg-secondary)] rounded-lg">
|
||||
<p className="text-sm text-[var(--text-secondary)] mb-2">Equipamiento</p>
|
||||
<p className="text-2xl font-bold text-[var(--text-primary)]">
|
||||
{resourceUtil.equipment_utilization.toFixed(1)}%
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-center p-4 bg-[var(--bg-secondary)] rounded-lg">
|
||||
<p className="text-sm text-[var(--text-secondary)] mb-2">Inventario</p>
|
||||
<p className="text-2xl font-bold text-[var(--text-primary)]">
|
||||
{resourceUtil.inventory_utilization.toFixed(1)}%
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-center p-4 bg-[var(--bg-secondary)] rounded-lg">
|
||||
<p className="text-sm text-[var(--text-secondary)] mb-2">Balance</p>
|
||||
<p className="text-lg font-bold text-[var(--text-primary)]">
|
||||
{resourceUtil.resource_balance === 'balanced' ? 'Equilibrado' : 'Desbalanceado'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Impacto de Calidad Tab */}
|
||||
{activeTab === 'quality' && !isLoading && (
|
||||
<>
|
||||
{/* Quality Index Overview */}
|
||||
{qualityIndex && (
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-4">
|
||||
Índice de Calidad General
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
|
||||
<div className="text-center p-6 bg-[var(--bg-secondary)] rounded-lg">
|
||||
<p className="text-sm text-[var(--text-secondary)] mb-2">Calidad de Producción</p>
|
||||
<p className="text-3xl font-bold text-[var(--text-primary)]">
|
||||
{qualityIndex.production_quality.toFixed(1)}%
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-center p-6 bg-[var(--bg-secondary)] rounded-lg">
|
||||
<p className="text-sm text-[var(--text-secondary)] mb-2">Calidad de Inventario</p>
|
||||
<p className="text-3xl font-bold text-[var(--text-primary)]">
|
||||
{qualityIndex.inventory_quality.toFixed(1)}%
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between p-4 bg-green-50 rounded-lg">
|
||||
<span className="text-sm font-medium">Índice de Calidad Combinado</span>
|
||||
<span className="text-2xl font-bold text-green-600">
|
||||
{qualityIndex.overall_quality_index.toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Quality Issues Breakdown */}
|
||||
{qualityIndex && (
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-4">
|
||||
Desglose de Problemas de Calidad
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between p-3 bg-[var(--bg-secondary)] rounded-lg">
|
||||
<span className="text-sm font-medium">Defectos de Producción</span>
|
||||
<span className="text-lg font-semibold text-red-600">
|
||||
{qualityIndex.quality_issues.production_defects.toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between p-3 bg-[var(--bg-secondary)] rounded-lg">
|
||||
<span className="text-sm font-medium">Desperdicio</span>
|
||||
<span className="text-lg font-semibold text-orange-600">
|
||||
{qualityIndex.quality_issues.waste_percentage.toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between p-3 bg-[var(--bg-secondary)] rounded-lg">
|
||||
<span className="text-sm font-medium">Items por Vencer</span>
|
||||
<span className="text-lg font-semibold text-yellow-600">
|
||||
{qualityIndex.quality_issues.expiring_items}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between p-3 bg-[var(--bg-secondary)] rounded-lg">
|
||||
<span className="text-sm font-medium">Stock Bajo Afectando Calidad</span>
|
||||
<span className="text-lg font-semibold text-yellow-600">
|
||||
{qualityIndex.quality_issues.low_stock_affecting_quality}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Optimización Tab */}
|
||||
{activeTab === 'optimization' && !isLoading && (
|
||||
<>
|
||||
{/* Cost-Revenue Analysis */}
|
||||
{costRevenue && (
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-4">
|
||||
Análisis de Rentabilidad
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
|
||||
<div className="text-center p-6 bg-[var(--bg-secondary)] rounded-lg">
|
||||
<p className="text-sm text-[var(--text-secondary)] mb-2">Ingresos Totales</p>
|
||||
<p className="text-3xl font-bold text-green-600">
|
||||
€{costRevenue.total_revenue.toLocaleString('es-ES')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-center p-6 bg-[var(--bg-secondary)] rounded-lg">
|
||||
<p className="text-sm text-[var(--text-secondary)] mb-2">Costos Estimados</p>
|
||||
<p className="text-3xl font-bold text-red-600">
|
||||
€{costRevenue.estimated_costs.toLocaleString('es-ES')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between p-4 bg-blue-50 rounded-lg">
|
||||
<span className="text-sm font-medium">Ratio Costo-Ingreso</span>
|
||||
<span className="text-xl font-bold text-[var(--color-primary)]">
|
||||
{costRevenue.cost_revenue_ratio.toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between p-4 bg-green-50 rounded-lg">
|
||||
<span className="text-sm font-medium">Margen de Beneficio</span>
|
||||
<span className="text-xl font-bold text-green-600">
|
||||
{costRevenue.profit_margin.toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Improvement Recommendations */}
|
||||
{bottlenecks && bottlenecks.total_bottlenecks > 0 && (
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-4">
|
||||
Recomendaciones de Mejora
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
<div className="p-4 bg-blue-50 border border-blue-200 rounded-lg">
|
||||
<h4 className="font-medium text-[var(--text-primary)] mb-2">
|
||||
Área más crítica: {bottlenecks.most_critical_area}
|
||||
</h4>
|
||||
<p className="text-sm text-[var(--text-secondary)]">
|
||||
Se han detectado {bottlenecks.critical_count} cuellos de botella críticos.
|
||||
Prioriza la optimización de esta área para mejorar el flujo general.
|
||||
</p>
|
||||
</div>
|
||||
{qualityIndex && qualityIndex.waste_impact > 5 && (
|
||||
<div className="p-4 bg-yellow-50 border border-yellow-200 rounded-lg">
|
||||
<h4 className="font-medium text-[var(--text-primary)] mb-2">
|
||||
Reducir Desperdicio
|
||||
</h4>
|
||||
<p className="text-sm text-[var(--text-secondary)]">
|
||||
El desperdicio actual es de {qualityIndex.waste_impact.toFixed(1)}%.
|
||||
Implementa controles de calidad más estrictos para reducir pérdidas.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{resourceUtil && resourceUtil.resource_balance === 'imbalanced' && (
|
||||
<div className="p-4 bg-orange-50 border border-orange-200 rounded-lg">
|
||||
<h4 className="font-medium text-[var(--text-primary)] mb-2">
|
||||
Balance de Recursos
|
||||
</h4>
|
||||
<p className="text-sm text-[var(--text-secondary)]">
|
||||
Los recursos están desbalanceados entre departamentos.
|
||||
Considera redistribuir para optimizar la utilización general.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Hourly Productivity */}
|
||||
{/* No Recommendations */}
|
||||
{(!bottlenecks || bottlenecks.total_bottlenecks === 0) && (
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-4">Eficiencia por Hora</h3>
|
||||
<div className="h-64 flex items-end space-x-1 justify-between">
|
||||
{productivityData.map((data, index) => (
|
||||
<div key={index} className="flex flex-col items-center flex-1">
|
||||
<div className="text-xs text-[var(--text-secondary)] mb-1">{data.efficiency}%</div>
|
||||
<div
|
||||
className="w-full bg-[var(--color-info)]/50 rounded-t"
|
||||
style={{
|
||||
height: `${(data.efficiency / 100) * 200}px`,
|
||||
minHeight: '8px',
|
||||
backgroundColor: data.efficiency >= 90 ? '#10B981' : data.efficiency >= 80 ? '#F59E0B' : '#EF4444'
|
||||
}}
|
||||
></div>
|
||||
<span className="text-xs text-[var(--text-tertiary)] mt-2 transform -rotate-45 origin-center">
|
||||
{data.hour}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-4 flex justify-center space-x-6 text-xs">
|
||||
<div className="flex items-center">
|
||||
<div className="w-3 h-3 bg-green-500 rounded mr-1"></div>
|
||||
<span>≥90% Excelente</span>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<div className="w-3 h-3 bg-yellow-500 rounded mr-1"></div>
|
||||
<span>80-89% Bueno</span>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<div className="w-3 h-3 bg-red-500 rounded mr-1"></div>
|
||||
<span><80% Bajo</span>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-4">
|
||||
Recomendaciones de Mejora
|
||||
</h3>
|
||||
<div className="text-center py-8">
|
||||
<Target className="w-12 h-12 mx-auto text-[var(--color-success)] mb-3" />
|
||||
<p className="text-[var(--text-secondary)]">
|
||||
¡Excelente! No se han detectado áreas críticas que requieran optimización inmediata.
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* Allows users to test different scenarios and see potential impacts on demand
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useTenantStore } from '../../../../stores';
|
||||
import { forecastingService } from '../../../../api/services/forecasting';
|
||||
@@ -39,8 +39,11 @@ import {
|
||||
ArrowDownRight,
|
||||
Play,
|
||||
Sparkles,
|
||||
Package,
|
||||
} from 'lucide-react';
|
||||
import { PageHeader } from '../../../../components/layout';
|
||||
import { useIngredients } from '../../../../api/hooks/inventory';
|
||||
import { useModels } from '../../../../api/hooks/training';
|
||||
|
||||
export const ScenarioSimulationPage: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
@@ -57,6 +60,43 @@ export const ScenarioSimulationPage: React.FC = () => {
|
||||
const [durationDays, setDurationDays] = useState(7);
|
||||
const [selectedProducts, setSelectedProducts] = useState<string[]>([]);
|
||||
|
||||
// Fetch real inventory data
|
||||
const {
|
||||
data: ingredientsData,
|
||||
isLoading: ingredientsLoading,
|
||||
} = useIngredients(currentTenant?.id || '');
|
||||
|
||||
// Fetch trained models to filter products
|
||||
const {
|
||||
data: modelsData,
|
||||
isLoading: modelsLoading,
|
||||
} = useModels(currentTenant?.id || '', { active_only: true });
|
||||
|
||||
// Build products list from ingredients that have trained models
|
||||
const availableProducts = useMemo(() => {
|
||||
if (!ingredientsData || !modelsData) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Handle both array and paginated response formats
|
||||
const modelsList = Array.isArray(modelsData) ? modelsData : (modelsData.models || modelsData.items || []);
|
||||
|
||||
// Get inventory product IDs that have trained models
|
||||
const modelProductIds = new Set(modelsList.map((model: any) => model.inventory_product_id));
|
||||
|
||||
// Filter ingredients to only those with models
|
||||
const ingredientsWithModels = ingredientsData.filter(ingredient =>
|
||||
modelProductIds.has(ingredient.id)
|
||||
);
|
||||
|
||||
return ingredientsWithModels.map(ingredient => ({
|
||||
id: ingredient.id,
|
||||
name: ingredient.name,
|
||||
category: ingredient.category || 'Other',
|
||||
hasModel: true
|
||||
}));
|
||||
}, [ingredientsData, modelsData]);
|
||||
|
||||
// Scenario-specific parameters
|
||||
const [weatherParams, setWeatherParams] = useState<WeatherScenario>({
|
||||
temperature_change: 15,
|
||||
@@ -81,6 +121,16 @@ export const ScenarioSimulationPage: React.FC = () => {
|
||||
promotion_type: 'discount',
|
||||
expected_traffic_increase: 0.3,
|
||||
});
|
||||
const [holidayParams, setHolidayParams] = useState({
|
||||
holiday_name: 'christmas',
|
||||
expected_impact_multiplier: 1.5,
|
||||
});
|
||||
const [supplyDisruptionParams, setSupplyDisruptionParams] = useState({
|
||||
severity: 'moderate',
|
||||
affected_percentage: 30,
|
||||
duration_days: 7,
|
||||
});
|
||||
const [customParams, setCustomParams] = useState<Record<string, number>>({});
|
||||
|
||||
const handleSimulate = async () => {
|
||||
if (!currentTenant?.id) return;
|
||||
@@ -119,6 +169,15 @@ export const ScenarioSimulationPage: React.FC = () => {
|
||||
case ScenarioType.PROMOTION:
|
||||
request.promotion_params = promotionParams;
|
||||
break;
|
||||
case ScenarioType.HOLIDAY:
|
||||
request.custom_multipliers = { holiday_multiplier: holidayParams.expected_impact_multiplier };
|
||||
break;
|
||||
case ScenarioType.SUPPLY_DISRUPTION:
|
||||
request.custom_multipliers = { disruption_severity: supplyDisruptionParams.affected_percentage / 100 };
|
||||
break;
|
||||
case ScenarioType.CUSTOM:
|
||||
request.custom_multipliers = customParams;
|
||||
break;
|
||||
}
|
||||
|
||||
const result = await forecastingService.simulateScenario(currentTenant.id, request);
|
||||
@@ -201,7 +260,7 @@ export const ScenarioSimulationPage: React.FC = () => {
|
||||
value={scenarioName}
|
||||
onChange={(e) => setScenarioName(e.target.value)}
|
||||
placeholder={t('analytics.scenario_simulation.scenario_name_placeholder', 'e.g., Summer Heatwave Impact')}
|
||||
className="w-full px-3 py-2 border rounded-lg"
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder-gray-500 dark:placeholder-gray-400 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -215,7 +274,7 @@ export const ScenarioSimulationPage: React.FC = () => {
|
||||
type="date"
|
||||
value={startDate}
|
||||
onChange={(e) => setStartDate(e.target.value)}
|
||||
className="w-full px-3 py-2 border rounded-lg"
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
min={new Date().toISOString().split('T')[0]}
|
||||
/>
|
||||
</div>
|
||||
@@ -229,10 +288,92 @@ export const ScenarioSimulationPage: React.FC = () => {
|
||||
onChange={(e) => setDurationDays(parseInt(e.target.value) || 7)}
|
||||
min={1}
|
||||
max={30}
|
||||
className="w-full px-3 py-2 border rounded-lg"
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Product Selection */}
|
||||
<div className="mt-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-sm font-medium flex items-center gap-2">
|
||||
<Package className="w-4 h-4" />
|
||||
{t('analytics.scenario_simulation.select_products', 'Select Products to Simulate')}
|
||||
</label>
|
||||
<span className="text-xs text-gray-500">
|
||||
{selectedProducts.length} selected
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{ingredientsLoading || modelsLoading ? (
|
||||
<div className="p-4 border rounded-lg text-center text-sm text-gray-500">
|
||||
Loading products...
|
||||
</div>
|
||||
) : availableProducts.length === 0 ? (
|
||||
<div className="p-4 border border-amber-200 bg-amber-50 rounded-lg">
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertTriangle className="w-4 h-4 text-amber-600 flex-shrink-0 mt-0.5" />
|
||||
<div className="text-sm text-amber-800">
|
||||
<p className="font-medium">No products available for simulation</p>
|
||||
<p className="mt-1">You need to train ML models for your products first. Visit the Training section to get started.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Quick Select All/None */}
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setSelectedProducts(availableProducts.map(p => p.id))}
|
||||
className="text-xs text-blue-600 hover:text-blue-700 font-medium"
|
||||
>
|
||||
Select All
|
||||
</button>
|
||||
<span className="text-xs text-gray-300">|</span>
|
||||
<button
|
||||
onClick={() => setSelectedProducts([])}
|
||||
className="text-xs text-gray-600 hover:text-gray-700 font-medium"
|
||||
>
|
||||
Clear All
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Product Grid */}
|
||||
<div className="max-h-64 overflow-y-auto border border-gray-300 dark:border-gray-600 rounded-lg p-3 space-y-2 bg-gray-50 dark:bg-gray-800">
|
||||
{availableProducts.map((product) => (
|
||||
<label
|
||||
key={product.id}
|
||||
className={`flex items-center gap-3 p-3 border rounded-lg cursor-pointer transition-all ${
|
||||
selectedProducts.includes(product.id)
|
||||
? 'border-blue-500 dark:border-blue-400 bg-blue-50 dark:bg-blue-900/30'
|
||||
: 'border-gray-200 dark:border-gray-600 bg-white dark:bg-gray-700 hover:border-gray-300 dark:hover:border-gray-500'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedProducts.includes(product.id)}
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) {
|
||||
setSelectedProducts([...selectedProducts, product.id]);
|
||||
} else {
|
||||
setSelectedProducts(selectedProducts.filter(id => id !== product.id));
|
||||
}
|
||||
}}
|
||||
className="w-4 h-4 text-blue-600 border-gray-300 dark:border-gray-500 rounded focus:ring-blue-500 dark:bg-gray-600"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">{product.name}</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">{product.category}</div>
|
||||
</div>
|
||||
<Badge variant="success" className="text-xs">
|
||||
ML Ready
|
||||
</Badge>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -274,7 +415,7 @@ export const ScenarioSimulationPage: React.FC = () => {
|
||||
type="number"
|
||||
value={weatherParams.temperature_change || 0}
|
||||
onChange={(e) => setWeatherParams({ ...weatherParams, temperature_change: parseFloat(e.target.value) })}
|
||||
className="w-full px-3 py-2 border rounded-lg mt-1"
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg mt-1 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
min={-30}
|
||||
max={30}
|
||||
/>
|
||||
@@ -284,7 +425,7 @@ export const ScenarioSimulationPage: React.FC = () => {
|
||||
<select
|
||||
value={weatherParams.weather_type || 'heatwave'}
|
||||
onChange={(e) => setWeatherParams({ ...weatherParams, weather_type: e.target.value })}
|
||||
className="w-full px-3 py-2 border rounded-lg mt-1"
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg mt-1 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
>
|
||||
<option value="heatwave">Heatwave</option>
|
||||
<option value="cold_snap">Cold Snap</option>
|
||||
@@ -303,7 +444,7 @@ export const ScenarioSimulationPage: React.FC = () => {
|
||||
type="number"
|
||||
value={competitionParams.new_competitors}
|
||||
onChange={(e) => setCompetitionParams({ ...competitionParams, new_competitors: parseInt(e.target.value) || 1 })}
|
||||
className="w-full px-3 py-2 border rounded-lg mt-1"
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg mt-1 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
min={1}
|
||||
max={10}
|
||||
/>
|
||||
@@ -315,7 +456,7 @@ export const ScenarioSimulationPage: React.FC = () => {
|
||||
step="0.1"
|
||||
value={competitionParams.distance_km}
|
||||
onChange={(e) => setCompetitionParams({ ...competitionParams, distance_km: parseFloat(e.target.value) })}
|
||||
className="w-full px-3 py-2 border rounded-lg mt-1"
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg mt-1 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
min={0.1}
|
||||
max={10}
|
||||
/>
|
||||
@@ -326,7 +467,7 @@ export const ScenarioSimulationPage: React.FC = () => {
|
||||
type="number"
|
||||
value={competitionParams.estimated_market_share_loss * 100}
|
||||
onChange={(e) => setCompetitionParams({ ...competitionParams, estimated_market_share_loss: parseFloat(e.target.value) / 100 })}
|
||||
className="w-full px-3 py-2 border rounded-lg mt-1"
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg mt-1 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
min={0}
|
||||
max={50}
|
||||
/>
|
||||
@@ -342,10 +483,24 @@ export const ScenarioSimulationPage: React.FC = () => {
|
||||
type="number"
|
||||
value={promotionParams.discount_percent}
|
||||
onChange={(e) => setPromotionParams({ ...promotionParams, discount_percent: parseFloat(e.target.value) })}
|
||||
className="w-full px-3 py-2 border rounded-lg mt-1"
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg mt-1 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
min={0}
|
||||
max={75}
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">Typical range: 10-30%</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm">Promotion Type</label>
|
||||
<select
|
||||
value={promotionParams.promotion_type}
|
||||
onChange={(e) => setPromotionParams({ ...promotionParams, promotion_type: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg mt-1 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
>
|
||||
<option value="discount">Discount</option>
|
||||
<option value="bogo">Buy One Get One</option>
|
||||
<option value="bundle">Bundle Deal</option>
|
||||
<option value="flash_sale">Flash Sale</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm">Expected Traffic Increase (%)</label>
|
||||
@@ -353,10 +508,208 @@ export const ScenarioSimulationPage: React.FC = () => {
|
||||
type="number"
|
||||
value={promotionParams.expected_traffic_increase * 100}
|
||||
onChange={(e) => setPromotionParams({ ...promotionParams, expected_traffic_increase: parseFloat(e.target.value) / 100 })}
|
||||
className="w-full px-3 py-2 border rounded-lg mt-1"
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg mt-1 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
min={0}
|
||||
max={200}
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">Most promotions see 20-50% increase</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedScenarioType === ScenarioType.EVENT && (
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="text-sm">Event Type</label>
|
||||
<select
|
||||
value={eventParams.event_type}
|
||||
onChange={(e) => setEventParams({ ...eventParams, event_type: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg mt-1 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
>
|
||||
<option value="festival">Festival</option>
|
||||
<option value="sports">Sports Event</option>
|
||||
<option value="concert">Concert</option>
|
||||
<option value="conference">Conference</option>
|
||||
<option value="market">Street Market</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm">Expected Attendance</label>
|
||||
<input
|
||||
type="number"
|
||||
value={eventParams.expected_attendance}
|
||||
onChange={(e) => setEventParams({ ...eventParams, expected_attendance: parseInt(e.target.value) || 0 })}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg mt-1 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
min={100}
|
||||
step={100}
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">Number of people expected</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm">Distance from Location (km)</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.1"
|
||||
value={eventParams.distance_km}
|
||||
onChange={(e) => setEventParams({ ...eventParams, distance_km: parseFloat(e.target.value) })}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg mt-1 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
min={0}
|
||||
max={50}
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">Closer events have bigger impact</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm">Event Duration (days)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={eventParams.duration_days}
|
||||
onChange={(e) => setEventParams({ ...eventParams, duration_days: parseInt(e.target.value) || 1 })}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg mt-1 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
min={1}
|
||||
max={30}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedScenarioType === ScenarioType.PRICING && (
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="text-sm">Price Change (%)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={pricingParams.price_change_percent}
|
||||
onChange={(e) => setPricingParams({ ...pricingParams, price_change_percent: parseFloat(e.target.value) })}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg mt-1 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
min={-50}
|
||||
max={100}
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Negative for price decrease, positive for increase
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-3 bg-blue-50 border border-blue-200 rounded-lg">
|
||||
<div className="text-xs text-blue-800">
|
||||
<p className="font-medium">💡 Pricing Impact Guide:</p>
|
||||
<ul className="mt-1 space-y-1 ml-4 list-disc">
|
||||
<li>-10% price: Usually +8-12% demand</li>
|
||||
<li>+10% price: Usually -8-12% demand</li>
|
||||
<li>Impact varies by product type and competition</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedScenarioType === ScenarioType.HOLIDAY && (
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="text-sm">Holiday Period</label>
|
||||
<select
|
||||
value={holidayParams.holiday_name}
|
||||
onChange={(e) => setHolidayParams({ ...holidayParams, holiday_name: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg mt-1 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
>
|
||||
<option value="christmas">Christmas</option>
|
||||
<option value="new_year">New Year</option>
|
||||
<option value="easter">Easter</option>
|
||||
<option value="valentines">Valentine's Day</option>
|
||||
<option value="mothers_day">Mother's Day</option>
|
||||
<option value="thanksgiving">Thanksgiving</option>
|
||||
<option value="halloween">Halloween</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm">Expected Impact Multiplier</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.1"
|
||||
value={holidayParams.expected_impact_multiplier}
|
||||
onChange={(e) => setHolidayParams({ ...holidayParams, expected_impact_multiplier: parseFloat(e.target.value) })}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg mt-1 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
min={0.5}
|
||||
max={3}
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
1.0 = no change, 1.5 = 50% increase, 0.8 = 20% decrease
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedScenarioType === ScenarioType.SUPPLY_DISRUPTION && (
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="text-sm">Disruption Severity</label>
|
||||
<select
|
||||
value={supplyDisruptionParams.severity}
|
||||
onChange={(e) => setSupplyDisruptionParams({ ...supplyDisruptionParams, severity: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg mt-1 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
>
|
||||
<option value="minor">Minor (10-20% affected)</option>
|
||||
<option value="moderate">Moderate (20-40% affected)</option>
|
||||
<option value="major">Major (40-60% affected)</option>
|
||||
<option value="severe">Severe (60%+ affected)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm">Affected Supply (%)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={supplyDisruptionParams.affected_percentage}
|
||||
onChange={(e) => setSupplyDisruptionParams({ ...supplyDisruptionParams, affected_percentage: parseInt(e.target.value) || 0 })}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg mt-1 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
min={0}
|
||||
max={100}
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Percentage of normal supply that will be unavailable
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm">Expected Duration (days)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={supplyDisruptionParams.duration_days}
|
||||
onChange={(e) => setSupplyDisruptionParams({ ...supplyDisruptionParams, duration_days: parseInt(e.target.value) || 1 })}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg mt-1 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
min={1}
|
||||
max={30}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedScenarioType === ScenarioType.CUSTOM && (
|
||||
<div className="space-y-3">
|
||||
<div className="p-3 bg-purple-50 border border-purple-200 rounded-lg">
|
||||
<p className="text-sm text-purple-800 font-medium">Custom Scenario</p>
|
||||
<p className="text-xs text-purple-700 mt-1">
|
||||
Define your own demand multiplier for unique situations
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm">Demand Multiplier</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.1"
|
||||
value={customParams.custom_multiplier || 1.0}
|
||||
onChange={(e) => setCustomParams({ custom_multiplier: parseFloat(e.target.value) })}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg mt-1 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
min={0}
|
||||
max={5}
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
1.0 = no change, 1.5 = 50% increase, 0.7 = 30% decrease
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm">Scenario Description (optional)</label>
|
||||
<textarea
|
||||
placeholder="Describe what you're simulating..."
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg mt-1 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -377,43 +730,138 @@ export const ScenarioSimulationPage: React.FC = () => {
|
||||
{/* Quick Examples */}
|
||||
<Card>
|
||||
<div className="p-6">
|
||||
<h3 className="text-sm font-semibold mb-3 flex items-center gap-2">
|
||||
<Lightbulb className="w-4 h-4" />
|
||||
{t('analytics.scenario_simulation.quick_examples', 'Quick Examples')}
|
||||
<h3 className="text-sm font-semibold mb-4 flex items-center gap-2">
|
||||
<Lightbulb className="w-5 h-5 text-yellow-500" />
|
||||
{t('analytics.scenario_simulation.quick_examples', 'Quick Start Templates')}
|
||||
</h3>
|
||||
<p className="text-xs text-gray-600 mb-4">
|
||||
Click any template to pre-fill the scenario parameters
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
setSelectedScenarioType(ScenarioType.WEATHER);
|
||||
setScenarioName('Summer Heatwave Next Week');
|
||||
setScenarioName('Summer Heatwave Impact');
|
||||
setWeatherParams({ temperature_change: 15, weather_type: 'heatwave' });
|
||||
setDurationDays(7);
|
||||
}}
|
||||
className="w-full text-left px-3 py-2 border rounded-lg hover:bg-gray-50 text-sm"
|
||||
className="w-full text-left px-4 py-3 border-2 border-orange-200 bg-orange-50 rounded-lg hover:bg-orange-100 hover:border-orange-300 transition-all group"
|
||||
>
|
||||
<Sun className="w-4 h-4 inline mr-2" />
|
||||
What if a heatwave hits next week?
|
||||
<div className="flex items-start gap-3">
|
||||
<Sun className="w-5 h-5 text-orange-600 mt-0.5" />
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-medium text-gray-900">Summer Heatwave</div>
|
||||
<div className="text-xs text-gray-600 mt-1">
|
||||
+15°C temperature spike - See impact on cold drinks & ice cream
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant="warning" className="text-xs">+20-40%</Badge>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
setSelectedScenarioType(ScenarioType.COMPETITION);
|
||||
setScenarioName('New Bakery Opening Nearby');
|
||||
setScenarioName('New Competitor Opening');
|
||||
setCompetitionParams({ new_competitors: 1, distance_km: 0.3, estimated_market_share_loss: 0.2 });
|
||||
setDurationDays(30);
|
||||
}}
|
||||
className="w-full text-left px-3 py-2 border rounded-lg hover:bg-gray-50 text-sm"
|
||||
className="w-full text-left px-4 py-3 border-2 border-red-200 bg-red-50 rounded-lg hover:bg-red-100 hover:border-red-300 transition-all"
|
||||
>
|
||||
<Users className="w-4 h-4 inline mr-2" />
|
||||
How would a new competitor affect sales?
|
||||
<div className="flex items-start gap-3">
|
||||
<Users className="w-5 h-5 text-red-600 mt-0.5" />
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-medium text-gray-900">New Nearby Competitor</div>
|
||||
<div className="text-xs text-gray-600 mt-1">
|
||||
Bakery opening 300m away - Est. 20% market share loss
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant="error" className="text-xs">-15-25%</Badge>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
setSelectedScenarioType(ScenarioType.PROMOTION);
|
||||
setScenarioName('Weekend Flash Sale');
|
||||
setPromotionParams({ discount_percent: 25, promotion_type: 'flash_sale', expected_traffic_increase: 0.5 });
|
||||
setDurationDays(3);
|
||||
}}
|
||||
className="w-full text-left px-3 py-2 border rounded-lg hover:bg-gray-50 text-sm"
|
||||
className="w-full text-left px-4 py-3 border-2 border-green-200 bg-green-50 rounded-lg hover:bg-green-100 hover:border-green-300 transition-all"
|
||||
>
|
||||
<Tag className="w-4 h-4 inline mr-2" />
|
||||
Impact of a 25% weekend promotion?
|
||||
<div className="flex items-start gap-3">
|
||||
<Tag className="w-5 h-5 text-green-600 mt-0.5" />
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-medium text-gray-900">Weekend Flash Sale</div>
|
||||
<div className="text-xs text-gray-600 mt-1">
|
||||
25% discount + 50% traffic boost - Test promotion impact
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant="success" className="text-xs">+40-60%</Badge>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
setSelectedScenarioType(ScenarioType.EVENT);
|
||||
setScenarioName('Local Festival Impact');
|
||||
setEventParams({ event_type: 'festival', expected_attendance: 5000, distance_km: 0.5, duration_days: 3 });
|
||||
setDurationDays(3);
|
||||
}}
|
||||
className="w-full text-left px-4 py-3 border-2 border-purple-200 bg-purple-50 rounded-lg hover:bg-purple-100 hover:border-purple-300 transition-all"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<Calendar className="w-5 h-5 text-purple-600 mt-0.5" />
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-medium text-gray-900">Local Festival</div>
|
||||
<div className="text-xs text-gray-600 mt-1">
|
||||
5,000 attendees 500m away - Capture festival traffic
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant="primary" className="text-xs">+30-50%</Badge>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
setSelectedScenarioType(ScenarioType.HOLIDAY);
|
||||
setScenarioName('Christmas Holiday Rush');
|
||||
setHolidayParams({ holiday_name: 'christmas', expected_impact_multiplier: 1.8 });
|
||||
setDurationDays(14);
|
||||
}}
|
||||
className="w-full text-left px-4 py-3 border-2 border-blue-200 bg-blue-50 rounded-lg hover:bg-blue-100 hover:border-blue-300 transition-all"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<Sparkles className="w-5 h-5 text-blue-600 mt-0.5" />
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-medium text-gray-900">Christmas Season</div>
|
||||
<div className="text-xs text-gray-600 mt-1">
|
||||
Holiday demand spike - Plan for seasonal products
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant="primary" className="text-xs">+60-100%</Badge>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
setSelectedScenarioType(ScenarioType.PRICING);
|
||||
setScenarioName('Price Adjustment Test');
|
||||
setPricingParams({ price_change_percent: -10 });
|
||||
setDurationDays(14);
|
||||
}}
|
||||
className="w-full text-left px-4 py-3 border-2 border-indigo-200 bg-indigo-50 rounded-lg hover:bg-indigo-100 hover:border-indigo-300 transition-all"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<TrendingUp className="w-5 h-5 text-indigo-600 mt-0.5" />
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-medium text-gray-900">Price Decrease</div>
|
||||
<div className="text-xs text-gray-600 mt-1">
|
||||
10% price reduction - Test elasticity and demand response
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant="info" className="text-xs">+8-15%</Badge>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -438,32 +886,53 @@ export const ScenarioSimulationPage: React.FC = () => {
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 mb-6">
|
||||
<div className="p-4 bg-gray-50 rounded-lg">
|
||||
<div className="text-sm text-gray-500 mb-1">Baseline Demand</div>
|
||||
<div className="text-2xl font-bold">{Math.round(simulationResult.total_baseline_demand)}</div>
|
||||
<div className="p-4 bg-gray-50 rounded-lg border border-gray-200">
|
||||
<div className="text-xs text-gray-500 mb-1 uppercase tracking-wide">Baseline Demand</div>
|
||||
<div className="text-2xl font-bold text-gray-900">{Math.round(simulationResult.total_baseline_demand)}</div>
|
||||
<div className="text-xs text-gray-500 mt-1">units expected normally</div>
|
||||
</div>
|
||||
<div className="p-4 bg-blue-50 rounded-lg">
|
||||
<div className="text-sm text-gray-500 mb-1">Scenario Demand</div>
|
||||
<div className="text-2xl font-bold">{Math.round(simulationResult.total_scenario_demand)}</div>
|
||||
<div className="p-4 bg-blue-50 rounded-lg border border-blue-200">
|
||||
<div className="text-xs text-blue-600 mb-1 uppercase tracking-wide">Scenario Demand</div>
|
||||
<div className="text-2xl font-bold text-blue-900">{Math.round(simulationResult.total_scenario_demand)}</div>
|
||||
<div className="text-xs text-blue-600 mt-1">units with this scenario</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-gradient-to-r from-blue-50 to-purple-50 rounded-lg">
|
||||
<div className={`p-5 rounded-xl border-2 ${
|
||||
simulationResult.overall_impact_percent > 0
|
||||
? 'bg-gradient-to-r from-green-50 to-emerald-50 border-green-200'
|
||||
: 'bg-gradient-to-r from-red-50 to-orange-50 border-red-200'
|
||||
}`}>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium">Overall Impact</span>
|
||||
<div>
|
||||
<div className="text-xs font-medium text-gray-600 mb-1 uppercase tracking-wide">Overall Impact</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{simulationResult.overall_impact_percent > 0 ? (
|
||||
<ArrowUpRight className="w-5 h-5 text-green-500" />
|
||||
<div className="flex items-center gap-1 text-green-700">
|
||||
<ArrowUpRight className="w-6 h-6" />
|
||||
<span className="text-3xl font-bold">
|
||||
+{simulationResult.overall_impact_percent.toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<ArrowDownRight className="w-5 h-5 text-red-500" />
|
||||
)}
|
||||
<span className={`text-2xl font-bold ${
|
||||
simulationResult.overall_impact_percent > 0 ? 'text-green-600' : 'text-red-600'
|
||||
}`}>
|
||||
{simulationResult.overall_impact_percent > 0 ? '+' : ''}
|
||||
<div className="flex items-center gap-1 text-red-700">
|
||||
<ArrowDownRight className="w-6 h-6" />
|
||||
<span className="text-3xl font-bold">
|
||||
{simulationResult.overall_impact_percent.toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className={`px-4 py-2 rounded-lg ${
|
||||
simulationResult.overall_impact_percent > 0
|
||||
? 'bg-green-100 text-green-800'
|
||||
: 'bg-red-100 text-red-800'
|
||||
}`}>
|
||||
<div className="text-xs font-medium">
|
||||
{simulationResult.overall_impact_percent > 0 ? '📈 Increased Demand' : '📉 Decreased Demand'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -472,15 +941,15 @@ export const ScenarioSimulationPage: React.FC = () => {
|
||||
{/* Insights */}
|
||||
<Card>
|
||||
<div className="p-6">
|
||||
<h3 className="text-sm font-semibold mb-3 flex items-center gap-2">
|
||||
<Lightbulb className="w-4 h-4" />
|
||||
{t('analytics.scenario_simulation.insights', 'Key Insights')}
|
||||
<h3 className="text-sm font-semibold mb-4 flex items-center gap-2">
|
||||
<Lightbulb className="w-5 h-5 text-yellow-500" />
|
||||
<span>{t('analytics.scenario_simulation.insights', 'Key Insights')}</span>
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-3">
|
||||
{simulationResult.insights.map((insight, index) => (
|
||||
<div key={index} className="flex items-start gap-2 text-sm">
|
||||
<CheckCircle className="w-4 h-4 text-blue-500 mt-0.5 flex-shrink-0" />
|
||||
<span>{insight}</span>
|
||||
<div key={index} className="flex items-start gap-3 p-3 bg-blue-50 border border-blue-100 rounded-lg">
|
||||
<CheckCircle className="w-5 h-5 text-blue-600 mt-0.5 flex-shrink-0" />
|
||||
<span className="text-sm text-gray-800">{insight}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -490,15 +959,17 @@ export const ScenarioSimulationPage: React.FC = () => {
|
||||
{/* Recommendations */}
|
||||
<Card>
|
||||
<div className="p-6">
|
||||
<h3 className="text-sm font-semibold mb-3 flex items-center gap-2">
|
||||
<TrendingUp className="w-4 h-4" />
|
||||
{t('analytics.scenario_simulation.recommendations', 'Recommendations')}
|
||||
<h3 className="text-sm font-semibold mb-4 flex items-center gap-2">
|
||||
<TrendingUp className="w-5 h-5 text-green-600" />
|
||||
<span>{t('analytics.scenario_simulation.recommendations', 'Action Plan')}</span>
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-3">
|
||||
{simulationResult.recommendations.map((recommendation, index) => (
|
||||
<div key={index} className="flex items-start gap-2 text-sm p-3 bg-blue-50 rounded-lg">
|
||||
<span className="font-medium text-blue-600">{index + 1}.</span>
|
||||
<span>{recommendation}</span>
|
||||
<div key={index} className="flex items-start gap-3 p-4 bg-gradient-to-r from-green-50 to-emerald-50 border border-green-200 rounded-lg hover:shadow-sm transition-shadow">
|
||||
<div className="flex items-center justify-center w-6 h-6 rounded-full bg-green-600 text-white font-bold text-xs flex-shrink-0">
|
||||
{index + 1}
|
||||
</div>
|
||||
<span className="text-sm text-gray-800 font-medium">{recommendation}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -509,29 +980,70 @@ export const ScenarioSimulationPage: React.FC = () => {
|
||||
{simulationResult.product_impacts.length > 0 && (
|
||||
<Card>
|
||||
<div className="p-6">
|
||||
<h3 className="text-sm font-semibold mb-3 flex items-center gap-2">
|
||||
<BarChart3 className="w-4 h-4" />
|
||||
{t('analytics.scenario_simulation.product_impacts', 'Product Impacts')}
|
||||
<h3 className="text-sm font-semibold mb-4 flex items-center gap-2">
|
||||
<BarChart3 className="w-5 h-5 text-purple-600" />
|
||||
<span>{t('analytics.scenario_simulation.product_impacts', 'Product-Level Impact')}</span>
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
{simulationResult.product_impacts.map((impact, index) => (
|
||||
<div key={index} className="p-3 border rounded-lg">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm font-medium">{impact.inventory_product_id}</span>
|
||||
<span className={`text-sm font-bold ${
|
||||
impact.demand_change_percent > 0 ? 'text-green-600' : 'text-red-600'
|
||||
{simulationResult.product_impacts.map((impact, index) => {
|
||||
const impactPercent = Math.abs(impact.demand_change_percent);
|
||||
const isPositive = impact.demand_change_percent > 0;
|
||||
|
||||
return (
|
||||
<div key={index} className={`p-4 border-2 rounded-lg ${
|
||||
isPositive ? 'border-green-200 bg-green-50' : 'border-red-200 bg-red-50'
|
||||
}`}>
|
||||
{impact.demand_change_percent > 0 ? '+' : ''}
|
||||
{impact.demand_change_percent.toFixed(1)}%
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Package className="w-4 h-4 text-gray-600" />
|
||||
<span className="text-sm font-semibold text-gray-900">
|
||||
{availableProducts.find(p => p.id === impact.inventory_product_id)?.name || impact.inventory_product_id}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-xs text-gray-500">
|
||||
<span>Baseline: {Math.round(impact.baseline_demand)}</span>
|
||||
<span>→</span>
|
||||
<span>Scenario: {Math.round(impact.simulated_demand)}</span>
|
||||
<div className={`flex items-center gap-1 px-3 py-1 rounded-full ${
|
||||
isPositive ? 'bg-green-100' : 'bg-red-100'
|
||||
}`}>
|
||||
{isPositive ? (
|
||||
<ArrowUpRight className="w-4 h-4 text-green-700" />
|
||||
) : (
|
||||
<ArrowDownRight className="w-4 h-4 text-red-700" />
|
||||
)}
|
||||
<span className={`text-sm font-bold ${
|
||||
isPositive ? 'text-green-700' : 'text-red-700'
|
||||
}`}>
|
||||
{isPositive ? '+' : ''}{impact.demand_change_percent.toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Visual bar showing demand change */}
|
||||
<div className="mb-2">
|
||||
<div className="flex items-center justify-between text-xs text-gray-600 mb-1">
|
||||
<span>Baseline: {Math.round(impact.baseline_demand)} units</span>
|
||||
<span>Scenario: {Math.round(impact.simulated_demand)} units</span>
|
||||
</div>
|
||||
<div className="w-full h-2 bg-gray-200 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full ${isPositive ? 'bg-green-500' : 'bg-red-500'}`}
|
||||
style={{ width: `${Math.min(impactPercent, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Traffic light risk indicator */}
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<div className="flex gap-1">
|
||||
<div className={`w-2 h-2 rounded-full ${impactPercent > 30 ? 'bg-red-500' : 'bg-gray-300'}`} />
|
||||
<div className={`w-2 h-2 rounded-full ${impactPercent > 15 && impactPercent <= 30 ? 'bg-yellow-500' : 'bg-gray-300'}`} />
|
||||
<div className={`w-2 h-2 rounded-full ${impactPercent <= 15 ? 'bg-green-500' : 'bg-gray-300'}`} />
|
||||
</div>
|
||||
<span className="text-xs text-gray-600">
|
||||
{impactPercent > 30 ? '🔴 High impact' : impactPercent > 15 ? '🟡 Medium impact' : '🟢 Low impact'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
@@ -205,13 +205,12 @@ export const DemoPage: React.FC = () => {
|
||||
</div>
|
||||
|
||||
<h1 className="text-4xl tracking-tight font-extrabold text-[var(--text-primary)] sm:text-5xl lg:text-6xl">
|
||||
<span className="block">Prueba BakeryIA</span>
|
||||
<span className="block">Prueba El Panadero Digital</span>
|
||||
<span className="block text-[var(--color-primary)]">sin compromiso</span>
|
||||
</h1>
|
||||
|
||||
<p className="mt-6 max-w-3xl mx-auto text-lg text-[var(--text-secondary)] sm:text-xl">
|
||||
Explora nuestro sistema con datos reales de panaderías españolas.
|
||||
Elige el tipo de negocio que mejor se adapte a tu caso.
|
||||
Elige el tipo de panadería que se ajuste a tu negocio
|
||||
</p>
|
||||
|
||||
<div className="mt-8 flex items-center justify-center space-x-6 text-sm text-[var(--text-tertiary)]">
|
||||
@@ -225,7 +224,7 @@ export const DemoPage: React.FC = () => {
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<Shield className="w-4 h-4 text-green-500 mr-2" />
|
||||
Datos aislados y seguros
|
||||
Datos reales en español
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -258,10 +257,14 @@ export const DemoPage: React.FC = () => {
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<h2 className="text-2xl font-bold text-[var(--text-primary)]">
|
||||
{account.name}
|
||||
{account.account_type === 'individual_bakery'
|
||||
? 'Panadería Individual con Producción local'
|
||||
: 'Panadería Franquiciada con Obrador Central'}
|
||||
</h2>
|
||||
<p className="text-sm text-[var(--text-tertiary)] mt-1">
|
||||
{account.business_model}
|
||||
{account.account_type === 'individual_bakery'
|
||||
? account.business_model
|
||||
: 'Punto de Venta + Obrador Central'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -275,9 +278,57 @@ export const DemoPage: React.FC = () => {
|
||||
{account.description}
|
||||
</p>
|
||||
|
||||
{/* Key Characteristics */}
|
||||
<div className="mb-6 p-4 bg-[var(--bg-secondary)] rounded-lg border border-[var(--border-default)]">
|
||||
<p className="text-xs font-semibold text-[var(--text-tertiary)] uppercase mb-3">
|
||||
Características del negocio
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||
{account.account_type === 'individual_bakery' ? (
|
||||
<>
|
||||
<div>
|
||||
<span className="text-[var(--text-tertiary)]">Empleados:</span>
|
||||
<span className="ml-2 font-semibold text-[var(--text-primary)]">~8</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-[var(--text-tertiary)]">Turnos:</span>
|
||||
<span className="ml-2 font-semibold text-[var(--text-primary)]">1/día</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-[var(--text-tertiary)]">Ventas:</span>
|
||||
<span className="ml-2 font-semibold text-[var(--text-primary)]">Directas</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-[var(--text-tertiary)]">Productos:</span>
|
||||
<span className="ml-2 font-semibold text-[var(--text-primary)]">Local</span>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div>
|
||||
<span className="text-[var(--text-tertiary)]">Empleados:</span>
|
||||
<span className="ml-2 font-semibold text-[var(--text-primary)]">~5-6</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-[var(--text-tertiary)]">Turnos:</span>
|
||||
<span className="ml-2 font-semibold text-[var(--text-primary)]">2/día</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-[var(--text-tertiary)]">Modelo:</span>
|
||||
<span className="ml-2 font-semibold text-[var(--text-primary)]">Franquicia</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-[var(--text-tertiary)]">Productos:</span>
|
||||
<span className="ml-2 font-semibold text-[var(--text-primary)]">De obrador</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Features */}
|
||||
{account.features && account.features.length > 0 && (
|
||||
<div className="mb-6 space-y-2">
|
||||
<div className="mb-8 space-y-2">
|
||||
<p className="text-sm font-semibold text-[var(--text-primary)] mb-3">
|
||||
Funcionalidades incluidas:
|
||||
</p>
|
||||
@@ -290,31 +341,16 @@ export const DemoPage: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Demo Benefits */}
|
||||
<div className="space-y-2 mb-8 pt-6 border-t border-[var(--border-default)]">
|
||||
<div className="flex items-center text-sm text-[var(--text-secondary)]">
|
||||
<Check className="w-4 h-4 mr-2 text-green-500" />
|
||||
Datos reales en español
|
||||
</div>
|
||||
<div className="flex items-center text-sm text-[var(--text-secondary)]">
|
||||
<Check className="w-4 h-4 mr-2 text-green-500" />
|
||||
Sesión aislada de 30 minutos
|
||||
</div>
|
||||
<div className="flex items-center text-sm text-[var(--text-secondary)]">
|
||||
<Check className="w-4 h-4 mr-2 text-green-500" />
|
||||
Sin necesidad de registro
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* CTA Button */}
|
||||
<Button
|
||||
onClick={() => handleStartDemo(account.account_type)}
|
||||
disabled={creatingSession}
|
||||
size="lg"
|
||||
className="w-full bg-[var(--color-primary)] hover:bg-[var(--color-primary-dark)] text-white shadow-lg hover:shadow-xl transform hover:scale-105 transition-all duration-200"
|
||||
className="w-full bg-gradient-to-r from-[var(--color-primary)] to-[var(--color-primary-dark)] hover:from-[var(--color-primary-dark)] hover:to-[var(--color-primary)] text-white shadow-lg hover:shadow-2xl transform hover:scale-[1.02] transition-all duration-200 font-semibold text-base py-4"
|
||||
>
|
||||
<Play className="mr-2 w-5 h-5" />
|
||||
Probar Demo Ahora
|
||||
Iniciar Demo
|
||||
<ArrowRight className="ml-2 w-5 h-5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -284,28 +284,28 @@ const LandingPage: React.FC = () => {
|
||||
<div className="bg-gradient-to-br from-amber-50 to-orange-50 dark:from-amber-900/20 dark:to-orange-900/20 rounded-2xl p-8 border-2 border-amber-200 dark:border-amber-800">
|
||||
<div className="flex items-center gap-4 mb-6">
|
||||
<div className="w-16 h-16 bg-amber-600 rounded-xl flex items-center justify-center">
|
||||
<Network className="w-8 h-8 text-white" />
|
||||
<Store className="w-8 h-8 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-2xl font-bold text-[var(--text-primary)]">{t('landing:business_models.central_workshop.title', 'Obrador Central + Puntos de Venta')}</h3>
|
||||
<p className="text-sm text-[var(--text-secondary)]">{t('landing:business_models.central_workshop.subtitle', 'Producción centralizada, distribución múltiple')}</p>
|
||||
<h3 className="text-2xl font-bold text-[var(--text-primary)]">{t('landing:business_models.central_workshop.title', 'Panadería Franquiciada')}</h3>
|
||||
<p className="text-sm text-[var(--text-secondary)]">{t('landing:business_models.central_workshop.subtitle', 'Punto de venta con obrador central')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-[var(--text-secondary)] mb-6 leading-relaxed">
|
||||
{t('landing:business_models.central_workshop.description', 'Produces centralmente y distribuyes a múltiples puntos de venta. Necesitas coordinar producción, logística y demanda entre ubicaciones para optimizar cada punto.')}
|
||||
{t('landing:business_models.central_workshop.description', 'Operas un punto de venta que recibe productos de un obrador central. Necesitas gestionar pedidos, inventario y ventas para optimizar tu operación retail.')}
|
||||
</p>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<Check className="w-5 h-5 text-amber-600 dark:text-amber-400 flex-shrink-0 mt-0.5" />
|
||||
<span className="text-sm text-[var(--text-secondary)]" dangerouslySetInnerHTML={{ __html: t('landing:business_models.central_workshop.features.prediction', '<strong>Predicción agregada y por punto de venta</strong> individual') }} />
|
||||
<span className="text-sm text-[var(--text-secondary)]" dangerouslySetInnerHTML={{ __html: t('landing:business_models.central_workshop.features.prediction', '<strong>Gestión de pedidos</strong> al obrador central') }} />
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<Check className="w-5 h-5 text-amber-600 dark:text-amber-400 flex-shrink-0 mt-0.5" />
|
||||
<span className="text-sm text-[var(--text-secondary)]" dangerouslySetInnerHTML={{ __html: t('landing:business_models.central_workshop.features.distribution', '<strong>Gestión de distribución</strong> multi-ubicación coordinada') }} />
|
||||
<span className="text-sm text-[var(--text-secondary)]" dangerouslySetInnerHTML={{ __html: t('landing:business_models.central_workshop.features.distribution', '<strong>Control de inventario</strong> de productos recibidos') }} />
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<Check className="w-5 h-5 text-amber-600 dark:text-amber-400 flex-shrink-0 mt-0.5" />
|
||||
<span className="text-sm text-[var(--text-secondary)]" dangerouslySetInnerHTML={{ __html: t('landing:business_models.central_workshop.features.visibility', '<strong>Visibilidad centralizada</strong> con control granular') }} />
|
||||
<span className="text-sm text-[var(--text-secondary)]" dangerouslySetInnerHTML={{ __html: t('landing:business_models.central_workshop.features.visibility', '<strong>Previsión de ventas</strong> para tu punto') }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
/**
|
||||
* Protected Route component for handling authentication and authorization
|
||||
*
|
||||
* This component integrates with the unified permission system to provide
|
||||
* comprehensive access control for routes. It checks both global user roles
|
||||
* and tenant-specific permissions.
|
||||
*
|
||||
* For permission checking logic, see utils/permissions.ts
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
@@ -8,6 +14,7 @@ import { useAuthUser, useIsAuthenticated, useAuthLoading } from '../stores';
|
||||
import { useCurrentTenantAccess, useTenantPermissions } from '../stores/tenant.store';
|
||||
import { useHasAccess, useIsDemoMode } from '../hooks/useAccessControl';
|
||||
import { RouteConfig, canAccessRoute, ROUTES } from './routes.config';
|
||||
import { checkCombinedPermission, type User, type TenantAccess } from '../utils/permissions';
|
||||
|
||||
interface ProtectedRouteProps {
|
||||
children: React.ReactNode;
|
||||
@@ -311,40 +318,46 @@ export const ConditionalRender: React.FC<ConditionalRenderProps> = ({
|
||||
};
|
||||
|
||||
// Route guard for admin-only routes (global admin or tenant owner/admin)
|
||||
// Uses unified permission system
|
||||
export const AdminRoute: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
return (
|
||||
<ConditionalRender
|
||||
requiredRoles={['admin', 'super_admin', 'owner']}
|
||||
requireAll={false}
|
||||
fallback={<UnauthorizedPage />}
|
||||
>
|
||||
{children}
|
||||
</ConditionalRender>
|
||||
);
|
||||
const user = useAuthUser();
|
||||
const tenantAccess = useCurrentTenantAccess();
|
||||
|
||||
// Check using unified permission system
|
||||
const hasAccess = checkCombinedPermission(user as User | undefined, tenantAccess as TenantAccess | undefined, {
|
||||
globalRoles: ['admin', 'super_admin'],
|
||||
tenantRoles: ['owner', 'admin']
|
||||
});
|
||||
|
||||
return hasAccess ? <>{children}</> : <UnauthorizedPage />;
|
||||
};
|
||||
|
||||
// Route guard for manager-level routes (global admin/manager or tenant admin/owner)
|
||||
// Uses unified permission system
|
||||
export const ManagerRoute: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
return (
|
||||
<ConditionalRender
|
||||
requiredRoles={['admin', 'super_admin', 'manager', 'owner']}
|
||||
requireAll={false}
|
||||
fallback={<UnauthorizedPage />}
|
||||
>
|
||||
{children}
|
||||
</ConditionalRender>
|
||||
);
|
||||
const user = useAuthUser();
|
||||
const tenantAccess = useCurrentTenantAccess();
|
||||
|
||||
// Check using unified permission system
|
||||
const hasAccess = checkCombinedPermission(user as User | undefined, tenantAccess as TenantAccess | undefined, {
|
||||
globalRoles: ['admin', 'super_admin', 'manager'],
|
||||
tenantRoles: ['owner', 'admin', 'member']
|
||||
});
|
||||
|
||||
return hasAccess ? <>{children}</> : <UnauthorizedPage />;
|
||||
};
|
||||
|
||||
// Route guard for tenant owner-only routes
|
||||
// Uses unified permission system
|
||||
export const OwnerRoute: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
return (
|
||||
<ConditionalRender
|
||||
requiredRoles={['super_admin', 'owner']}
|
||||
requireAll={false}
|
||||
fallback={<UnauthorizedPage />}
|
||||
>
|
||||
{children}
|
||||
</ConditionalRender>
|
||||
);
|
||||
const user = useAuthUser();
|
||||
const tenantAccess = useCurrentTenantAccess();
|
||||
|
||||
// Check using unified permission system
|
||||
const hasAccess = checkCombinedPermission(user as User | undefined, tenantAccess as TenantAccess | undefined, {
|
||||
globalRoles: ['super_admin'],
|
||||
tenantRoles: ['owner']
|
||||
});
|
||||
|
||||
return hasAccess ? <>{children}</> : <UnauthorizedPage />;
|
||||
};
|
||||
@@ -1,21 +1,47 @@
|
||||
/**
|
||||
* Role Types - Must match backend role definitions exactly
|
||||
*
|
||||
* This system uses TWO DISTINCT role systems for fine-grained access control:
|
||||
*
|
||||
* 1. GLOBAL USER ROLES (Auth Service):
|
||||
* - System-wide permissions across the platform
|
||||
* - Managed by the Auth service
|
||||
* - Stored in the User model
|
||||
* - Used for cross-tenant operations and platform administration
|
||||
*
|
||||
* 2. TENANT-SPECIFIC ROLES (Tenant Service):
|
||||
* - Organization/tenant-level permissions
|
||||
* - Managed by the Tenant service
|
||||
* - Stored in the TenantMember model
|
||||
* - Used for per-tenant access control and team management
|
||||
*
|
||||
* ROLE MAPPING (Tenant → Global):
|
||||
* When creating users through tenant management, tenant roles are mapped to global roles:
|
||||
* - tenant 'admin' → global 'admin' (full administrative access)
|
||||
* - tenant 'member' → global 'manager' (management-level access)
|
||||
* - tenant 'viewer' → global 'user' (basic user access)
|
||||
* - tenant 'owner' → No automatic mapping (owner is tenant-specific)
|
||||
*
|
||||
* This mapping ensures users have appropriate platform-level permissions
|
||||
* that align with their organizational role.
|
||||
*/
|
||||
|
||||
// Global User Roles (Auth Service)
|
||||
// Platform-wide roles for system-level access control
|
||||
export const GLOBAL_USER_ROLES = {
|
||||
USER: 'user',
|
||||
ADMIN: 'admin',
|
||||
MANAGER: 'manager',
|
||||
SUPER_ADMIN: 'super_admin',
|
||||
USER: 'user', // Basic authenticated user
|
||||
ADMIN: 'admin', // System administrator
|
||||
MANAGER: 'manager', // Mid-level management access
|
||||
SUPER_ADMIN: 'super_admin', // Full platform access
|
||||
} as const;
|
||||
|
||||
// Tenant-Specific Roles (Tenant Service)
|
||||
// Organization-level roles for tenant-scoped operations
|
||||
export const TENANT_ROLES = {
|
||||
OWNER: 'owner',
|
||||
ADMIN: 'admin',
|
||||
MEMBER: 'member',
|
||||
VIEWER: 'viewer',
|
||||
OWNER: 'owner', // Tenant owner (full control, can transfer ownership)
|
||||
ADMIN: 'admin', // Tenant administrator (team management, most operations)
|
||||
MEMBER: 'member', // Standard team member (regular operations)
|
||||
VIEWER: 'viewer', // Read-only observer (view-only access)
|
||||
} as const;
|
||||
|
||||
// Combined role types
|
||||
|
||||
380
frontend/src/utils/permissions.ts
Normal file
380
frontend/src/utils/permissions.ts
Normal file
@@ -0,0 +1,380 @@
|
||||
/**
|
||||
* Unified Permission Checking Utility
|
||||
*
|
||||
* This module provides a centralized system for checking permissions across
|
||||
* both global user roles and tenant-specific roles.
|
||||
*
|
||||
* WHEN TO USE WHICH PERMISSION CHECK:
|
||||
*
|
||||
* 1. Use checkGlobalPermission() for:
|
||||
* - Platform-wide features (user management, system settings)
|
||||
* - Cross-tenant operations
|
||||
* - Administrative tools
|
||||
* - Features that aren't tenant-specific
|
||||
*
|
||||
* 2. Use checkTenantPermission() for:
|
||||
* - Tenant-scoped operations (team management, tenant settings)
|
||||
* - Resource access within a tenant (orders, inventory, recipes)
|
||||
* - Organization-specific features
|
||||
* - Most application features
|
||||
*
|
||||
* 3. Use checkCombinedPermission() for:
|
||||
* - Features that require EITHER global OR tenant permissions
|
||||
* - Mixed access scenarios (e.g., super admin OR tenant owner)
|
||||
* - Fallback permission checks
|
||||
*
|
||||
* EXAMPLES:
|
||||
*
|
||||
* // Check if user can manage platform users (global only)
|
||||
* checkGlobalPermission(user, { requiredRole: 'admin' })
|
||||
*
|
||||
* // Check if user can manage tenant team (tenant only)
|
||||
* checkTenantPermission(tenantAccess, { requiredRole: 'owner' })
|
||||
*
|
||||
* // Check if user can access a feature (either global admin or tenant owner)
|
||||
* checkCombinedPermission(user, tenantAccess, {
|
||||
* globalRoles: ['admin', 'super_admin'],
|
||||
* tenantRoles: ['owner']
|
||||
* })
|
||||
*/
|
||||
|
||||
import {
|
||||
GLOBAL_USER_ROLES,
|
||||
TENANT_ROLES,
|
||||
ROLE_HIERARCHY,
|
||||
hasGlobalRole,
|
||||
hasTenantRole,
|
||||
type GlobalUserRole,
|
||||
type TenantRole,
|
||||
type Role
|
||||
} from '../types/roles';
|
||||
|
||||
/**
|
||||
* User object structure (from Auth service)
|
||||
*/
|
||||
export interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
role: GlobalUserRole;
|
||||
full_name?: string;
|
||||
is_active: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tenant access object structure (from Tenant service)
|
||||
*/
|
||||
export interface TenantAccess {
|
||||
has_access: boolean;
|
||||
role: TenantRole;
|
||||
permissions: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Permission check options for global permissions
|
||||
*/
|
||||
export interface GlobalPermissionOptions {
|
||||
requiredRole: GlobalUserRole;
|
||||
allowHigherRoles?: boolean; // Default: true
|
||||
}
|
||||
|
||||
/**
|
||||
* Permission check options for tenant permissions
|
||||
*/
|
||||
export interface TenantPermissionOptions {
|
||||
requiredRole?: TenantRole;
|
||||
requiredPermission?: string;
|
||||
allowHigherRoles?: boolean; // Default: true
|
||||
}
|
||||
|
||||
/**
|
||||
* Permission check options for combined (global + tenant) permissions
|
||||
*/
|
||||
export interface CombinedPermissionOptions {
|
||||
globalRoles?: GlobalUserRole[];
|
||||
tenantRoles?: TenantRole[];
|
||||
tenantPermissions?: string[];
|
||||
requireBoth?: boolean; // Default: false (OR logic), true for AND logic
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a user has a specific global permission
|
||||
*
|
||||
* @param user - User object from auth store
|
||||
* @param options - Permission requirements
|
||||
* @returns true if user has the required global permission
|
||||
*
|
||||
* @example
|
||||
* // Check if user is an admin
|
||||
* checkGlobalPermission(user, { requiredRole: 'admin' })
|
||||
*
|
||||
* // Check if user is exactly a manager (no higher roles)
|
||||
* checkGlobalPermission(user, { requiredRole: 'manager', allowHigherRoles: false })
|
||||
*/
|
||||
export function checkGlobalPermission(
|
||||
user: User | null | undefined,
|
||||
options: GlobalPermissionOptions
|
||||
): boolean {
|
||||
if (!user || !user.is_active) return false;
|
||||
|
||||
const { requiredRole, allowHigherRoles = true } = options;
|
||||
|
||||
if (allowHigherRoles) {
|
||||
// Check if user has the required role or higher
|
||||
return hasGlobalRole(user.role, requiredRole);
|
||||
} else {
|
||||
// Check for exact role match
|
||||
return user.role === requiredRole;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a user has a specific tenant permission
|
||||
*
|
||||
* @param tenantAccess - Tenant access object from tenant store
|
||||
* @param options - Permission requirements
|
||||
* @returns true if user has the required tenant permission
|
||||
*
|
||||
* @example
|
||||
* // Check if user is a tenant owner
|
||||
* checkTenantPermission(tenantAccess, { requiredRole: 'owner' })
|
||||
*
|
||||
* // Check if user has a specific permission
|
||||
* checkTenantPermission(tenantAccess, { requiredPermission: 'manage_team' })
|
||||
*
|
||||
* // Check if user is exactly an admin (no higher roles)
|
||||
* checkTenantPermission(tenantAccess, { requiredRole: 'admin', allowHigherRoles: false })
|
||||
*/
|
||||
export function checkTenantPermission(
|
||||
tenantAccess: TenantAccess | null | undefined,
|
||||
options: TenantPermissionOptions
|
||||
): boolean {
|
||||
if (!tenantAccess || !tenantAccess.has_access) return false;
|
||||
|
||||
const { requiredRole, requiredPermission, allowHigherRoles = true } = options;
|
||||
|
||||
// Check role-based permission
|
||||
if (requiredRole) {
|
||||
if (allowHigherRoles) {
|
||||
if (!hasTenantRole(tenantAccess.role, requiredRole)) {
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
if (tenantAccess.role !== requiredRole) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check specific permission
|
||||
if (requiredPermission) {
|
||||
if (!tenantAccess.permissions?.includes(requiredPermission)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check combined global and tenant permissions
|
||||
*
|
||||
* @param user - User object from auth store
|
||||
* @param tenantAccess - Tenant access object from tenant store
|
||||
* @param options - Permission requirements
|
||||
* @returns true if user meets the permission criteria
|
||||
*
|
||||
* @example
|
||||
* // Check if user is either global admin OR tenant owner (OR logic)
|
||||
* checkCombinedPermission(user, tenantAccess, {
|
||||
* globalRoles: ['admin', 'super_admin'],
|
||||
* tenantRoles: ['owner']
|
||||
* })
|
||||
*
|
||||
* // Check if user is global admin AND tenant member (AND logic)
|
||||
* checkCombinedPermission(user, tenantAccess, {
|
||||
* globalRoles: ['admin'],
|
||||
* tenantRoles: ['member', 'admin', 'owner'],
|
||||
* requireBoth: true
|
||||
* })
|
||||
*/
|
||||
export function checkCombinedPermission(
|
||||
user: User | null | undefined,
|
||||
tenantAccess: TenantAccess | null | undefined,
|
||||
options: CombinedPermissionOptions
|
||||
): boolean {
|
||||
const {
|
||||
globalRoles = [],
|
||||
tenantRoles = [],
|
||||
tenantPermissions = [],
|
||||
requireBoth = false
|
||||
} = options;
|
||||
|
||||
// Check global roles
|
||||
const hasGlobalAccess = globalRoles.length === 0 || (
|
||||
user?.is_active &&
|
||||
globalRoles.some(role => hasGlobalRole(user.role, role))
|
||||
);
|
||||
|
||||
// Check tenant roles
|
||||
const hasTenantRoleAccess = tenantRoles.length === 0 || (
|
||||
tenantAccess?.has_access &&
|
||||
tenantRoles.some(role => hasTenantRole(tenantAccess.role, role))
|
||||
);
|
||||
|
||||
// Check tenant permissions
|
||||
const hasTenantPermissionAccess = tenantPermissions.length === 0 || (
|
||||
tenantAccess?.has_access &&
|
||||
tenantPermissions.some(perm => tenantAccess.permissions?.includes(perm))
|
||||
);
|
||||
|
||||
// Combine tenant role and permission checks (must pass at least one)
|
||||
const hasTenantAccess = hasTenantRoleAccess || hasTenantPermissionAccess;
|
||||
|
||||
if (requireBoth) {
|
||||
// AND logic: must have both global and tenant access
|
||||
return hasGlobalAccess && hasTenantAccess;
|
||||
} else {
|
||||
// OR logic: must have either global or tenant access
|
||||
return hasGlobalAccess || hasTenantAccess;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user can manage team members
|
||||
*
|
||||
* @param user - User object
|
||||
* @param tenantAccess - Tenant access object
|
||||
* @returns true if user can manage team
|
||||
*
|
||||
* @example
|
||||
* canManageTeam(user, tenantAccess)
|
||||
*/
|
||||
export function canManageTeam(
|
||||
user: User | null | undefined,
|
||||
tenantAccess: TenantAccess | null | undefined
|
||||
): boolean {
|
||||
return checkCombinedPermission(user, tenantAccess, {
|
||||
globalRoles: [GLOBAL_USER_ROLES.ADMIN, GLOBAL_USER_ROLES.SUPER_ADMIN],
|
||||
tenantRoles: [TENANT_ROLES.OWNER, TENANT_ROLES.ADMIN]
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user is tenant owner
|
||||
*
|
||||
* @param user - User object
|
||||
* @param tenantAccess - Tenant access object
|
||||
* @returns true if user is owner
|
||||
*
|
||||
* @example
|
||||
* isTenantOwner(user, tenantAccess)
|
||||
*/
|
||||
export function isTenantOwner(
|
||||
user: User | null | undefined,
|
||||
tenantAccess: TenantAccess | null | undefined
|
||||
): boolean {
|
||||
return checkCombinedPermission(user, tenantAccess, {
|
||||
globalRoles: [GLOBAL_USER_ROLES.SUPER_ADMIN], // Super admin can act as owner
|
||||
tenantRoles: [TENANT_ROLES.OWNER]
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user can perform administrative actions
|
||||
*
|
||||
* @param user - User object
|
||||
* @returns true if user has admin access
|
||||
*
|
||||
* @example
|
||||
* canPerformAdminActions(user)
|
||||
*/
|
||||
export function canPerformAdminActions(
|
||||
user: User | null | undefined
|
||||
): boolean {
|
||||
return checkGlobalPermission(user, {
|
||||
requiredRole: GLOBAL_USER_ROLES.ADMIN
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user's effective permissions for a tenant
|
||||
*
|
||||
* @param user - User object
|
||||
* @param tenantAccess - Tenant access object
|
||||
* @returns Object with permission flags
|
||||
*
|
||||
* @example
|
||||
* const perms = getEffectivePermissions(user, tenantAccess)
|
||||
* if (perms.canManageTeam) { ... }
|
||||
*/
|
||||
export function getEffectivePermissions(
|
||||
user: User | null | undefined,
|
||||
tenantAccess: TenantAccess | null | undefined
|
||||
) {
|
||||
return {
|
||||
// Global permissions
|
||||
isGlobalAdmin: checkGlobalPermission(user, { requiredRole: GLOBAL_USER_ROLES.ADMIN }),
|
||||
isSuperAdmin: checkGlobalPermission(user, { requiredRole: GLOBAL_USER_ROLES.SUPER_ADMIN }),
|
||||
isManager: checkGlobalPermission(user, { requiredRole: GLOBAL_USER_ROLES.MANAGER }),
|
||||
|
||||
// Tenant permissions
|
||||
isTenantOwner: checkTenantPermission(tenantAccess, { requiredRole: TENANT_ROLES.OWNER }),
|
||||
isTenantAdmin: checkTenantPermission(tenantAccess, { requiredRole: TENANT_ROLES.ADMIN }),
|
||||
isTenantMember: checkTenantPermission(tenantAccess, { requiredRole: TENANT_ROLES.MEMBER }),
|
||||
isTenantViewer: checkTenantPermission(tenantAccess, { requiredRole: TENANT_ROLES.VIEWER }),
|
||||
|
||||
// Combined permissions
|
||||
canManageTeam: canManageTeam(user, tenantAccess),
|
||||
canTransferOwnership: isTenantOwner(user, tenantAccess),
|
||||
canPerformAdminActions: canPerformAdminActions(user),
|
||||
|
||||
// Access flags
|
||||
hasGlobalAccess: !!user?.is_active,
|
||||
hasTenantAccess: !!tenantAccess?.has_access,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Permission validation error types
|
||||
*/
|
||||
export class PermissionError extends Error {
|
||||
constructor(message: string, public readonly requiredPermissions: string[]) {
|
||||
super(message);
|
||||
this.name = 'PermissionError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert that user has required permissions, throw error if not
|
||||
*
|
||||
* @param user - User object
|
||||
* @param tenantAccess - Tenant access object
|
||||
* @param options - Permission requirements
|
||||
* @throws PermissionError if user lacks required permissions
|
||||
*
|
||||
* @example
|
||||
* assertPermission(user, tenantAccess, {
|
||||
* tenantRoles: ['owner'],
|
||||
* errorMessage: 'Only tenant owners can perform this action'
|
||||
* })
|
||||
*/
|
||||
export function assertPermission(
|
||||
user: User | null | undefined,
|
||||
tenantAccess: TenantAccess | null | undefined,
|
||||
options: CombinedPermissionOptions & { errorMessage?: string }
|
||||
): void {
|
||||
const hasPermission = checkCombinedPermission(user, tenantAccess, options);
|
||||
|
||||
if (!hasPermission) {
|
||||
const requiredPerms = [
|
||||
...(options.globalRoles || []),
|
||||
...(options.tenantRoles || []),
|
||||
...(options.tenantPermissions || [])
|
||||
];
|
||||
|
||||
throw new PermissionError(
|
||||
options.errorMessage || 'You do not have permission to perform this action',
|
||||
requiredPerms
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -141,15 +141,40 @@ async def proxy_tenant_external(request: Request, tenant_id: str = Path(...), pa
|
||||
target_path = f"/api/v1/tenants/{tenant_id}/external/{path}".rstrip("/")
|
||||
return await _proxy_to_external_service(request, target_path)
|
||||
|
||||
# Service-specific analytics routes (must come BEFORE the general analytics route)
|
||||
@router.api_route("/{tenant_id}/procurement/analytics/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"])
|
||||
async def proxy_tenant_procurement_analytics(request: Request, tenant_id: str = Path(...), path: str = ""):
|
||||
"""Proxy tenant procurement analytics requests to procurement service"""
|
||||
target_path = f"/api/v1/tenants/{tenant_id}/procurement/analytics/{path}".rstrip("/")
|
||||
return await _proxy_to_procurement_service(request, target_path, tenant_id=tenant_id)
|
||||
|
||||
@router.api_route("/{tenant_id}/inventory/analytics/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"])
|
||||
async def proxy_tenant_inventory_analytics(request: Request, tenant_id: str = Path(...), path: str = ""):
|
||||
"""Proxy tenant inventory analytics requests to inventory service"""
|
||||
target_path = f"/api/v1/tenants/{tenant_id}/inventory/analytics/{path}".rstrip("/")
|
||||
return await _proxy_to_inventory_service(request, target_path, tenant_id=tenant_id)
|
||||
|
||||
@router.api_route("/{tenant_id}/production/analytics/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"])
|
||||
async def proxy_tenant_production_analytics(request: Request, tenant_id: str = Path(...), path: str = ""):
|
||||
"""Proxy tenant production analytics requests to production service"""
|
||||
target_path = f"/api/v1/tenants/{tenant_id}/production/analytics/{path}".rstrip("/")
|
||||
return await _proxy_to_production_service(request, target_path, tenant_id=tenant_id)
|
||||
|
||||
@router.api_route("/{tenant_id}/sales/analytics/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"])
|
||||
async def proxy_tenant_sales_analytics(request: Request, tenant_id: str = Path(...), path: str = ""):
|
||||
"""Proxy tenant sales analytics requests to sales service"""
|
||||
target_path = f"/api/v1/tenants/{tenant_id}/sales/analytics/{path}".rstrip("/")
|
||||
return await _proxy_to_sales_service(request, target_path)
|
||||
|
||||
@router.api_route("/{tenant_id}/analytics/{path:path}", methods=["GET", "POST", "OPTIONS"])
|
||||
async def proxy_tenant_analytics(request: Request, tenant_id: str = Path(...), path: str = ""):
|
||||
"""Proxy tenant analytics requests to sales service"""
|
||||
"""Proxy tenant analytics requests to sales service (fallback for non-service-specific analytics)"""
|
||||
target_path = f"/api/v1/tenants/{tenant_id}/analytics/{path}".rstrip("/")
|
||||
return await _proxy_to_sales_service(request, target_path)
|
||||
|
||||
@router.api_route("/{tenant_id}/onboarding/{path:path}", methods=["GET", "POST", "OPTIONS"])
|
||||
async def proxy_tenant_analytics(request: Request, tenant_id: str = Path(...), path: str = ""):
|
||||
"""Proxy tenant analytics requests to sales service"""
|
||||
async def proxy_tenant_onboarding(request: Request, tenant_id: str = Path(...), path: str = ""):
|
||||
"""Proxy tenant onboarding requests to sales service"""
|
||||
target_path = f"/api/v1/tenants/{tenant_id}/onboarding/{path}".rstrip("/")
|
||||
return await _proxy_to_sales_service(request, target_path)
|
||||
|
||||
|
||||
@@ -36,9 +36,9 @@ async def get_demo_accounts():
|
||||
else "Punto de venta con obrador central"
|
||||
),
|
||||
"features": (
|
||||
["Gestión de Producción", "Recetas", "Inventario", "Previsión de Demanda", "Ventas"]
|
||||
["Gestión de Producción", "Recetas", "Inventario", "Ventas", "Previsión de Demanda"]
|
||||
if account_type == "individual_bakery"
|
||||
else ["Gestión de Proveedores", "Inventario", "Ventas", "Pedidos", "Previsión"]
|
||||
else ["Gestión de Proveedores", "Pedidos", "Inventario", "Ventas", "Previsión de Demanda"]
|
||||
),
|
||||
"business_model": (
|
||||
"Producción Local" if account_type == "individual_bakery" else "Obrador Central + Punto de Venta"
|
||||
|
||||
@@ -112,7 +112,13 @@ async def simulate_scenario(
|
||||
tenant_id=tenant_id,
|
||||
request=forecast_request
|
||||
)
|
||||
baseline_forecasts.extend(multi_day_result.get("forecasts", []))
|
||||
# Convert forecast dictionaries to ForecastResponse objects
|
||||
forecast_dicts = multi_day_result.get("forecasts", [])
|
||||
for forecast_dict in forecast_dicts:
|
||||
if isinstance(forecast_dict, dict):
|
||||
baseline_forecasts.append(ForecastResponse(**forecast_dict))
|
||||
else:
|
||||
baseline_forecasts.append(forecast_dict)
|
||||
|
||||
# Step 2: Apply scenario adjustments to generate scenario forecasts
|
||||
scenario_forecasts = await _apply_scenario_adjustments(
|
||||
|
||||
82
services/procurement/app/api/analytics.py
Normal file
82
services/procurement/app/api/analytics.py
Normal file
@@ -0,0 +1,82 @@
|
||||
# services/procurement/app/api/analytics.py
|
||||
"""
|
||||
Procurement Analytics API - Reporting, statistics, and insights
|
||||
Professional+ tier subscription required
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Path
|
||||
from typing import Optional, Dict, Any
|
||||
from uuid import UUID
|
||||
from datetime import datetime
|
||||
import structlog
|
||||
|
||||
from app.services.procurement_service import ProcurementService
|
||||
from shared.routing import RouteBuilder
|
||||
from shared.auth.access_control import analytics_tier_required
|
||||
from shared.auth.decorators import get_current_user_dep
|
||||
from app.core.database import get_db
|
||||
from app.core.config import settings
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
route_builder = RouteBuilder('procurement')
|
||||
router = APIRouter(tags=["procurement-analytics"])
|
||||
logger = structlog.get_logger()
|
||||
|
||||
|
||||
def get_procurement_service(db: AsyncSession = Depends(get_db)) -> ProcurementService:
|
||||
"""Dependency injection for ProcurementService"""
|
||||
return ProcurementService(db, settings)
|
||||
|
||||
|
||||
@router.get(
|
||||
route_builder.build_analytics_route("procurement")
|
||||
)
|
||||
@analytics_tier_required
|
||||
async def get_procurement_analytics(
|
||||
tenant_id: UUID = Path(..., description="Tenant ID"),
|
||||
start_date: Optional[datetime] = Query(None, description="Start date filter"),
|
||||
end_date: Optional[datetime] = Query(None, description="End date filter"),
|
||||
current_user: Dict[str, Any] = Depends(get_current_user_dep),
|
||||
procurement_service: ProcurementService = Depends(get_procurement_service)
|
||||
):
|
||||
"""Get procurement analytics dashboard for a tenant (Professional+ tier required)"""
|
||||
try:
|
||||
# Call the service method to get actual analytics data
|
||||
analytics_data = await procurement_service.get_procurement_analytics(
|
||||
tenant_id=tenant_id,
|
||||
start_date=start_date,
|
||||
end_date=end_date
|
||||
)
|
||||
|
||||
logger.info("Retrieved procurement analytics", tenant_id=tenant_id)
|
||||
return analytics_data
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to get procurement analytics", error=str(e), tenant_id=tenant_id)
|
||||
raise HTTPException(status_code=500, detail=f"Failed to get procurement analytics: {str(e)}")
|
||||
|
||||
|
||||
@router.get(
|
||||
route_builder.build_analytics_route("procurement/trends")
|
||||
)
|
||||
@analytics_tier_required
|
||||
async def get_procurement_trends(
|
||||
tenant_id: UUID = Path(..., description="Tenant ID"),
|
||||
days: int = Query(7, description="Number of days to retrieve trends for", ge=1, le=90),
|
||||
current_user: Dict[str, Any] = Depends(get_current_user_dep),
|
||||
procurement_service: ProcurementService = Depends(get_procurement_service)
|
||||
):
|
||||
"""Get procurement time-series trends for charts (Professional+ tier required)"""
|
||||
try:
|
||||
# Call the service method to get trends data
|
||||
trends_data = await procurement_service.get_procurement_trends(
|
||||
tenant_id=tenant_id,
|
||||
days=days
|
||||
)
|
||||
|
||||
logger.info("Retrieved procurement trends", tenant_id=tenant_id, days=days)
|
||||
return trends_data
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to get procurement trends", error=str(e), tenant_id=tenant_id)
|
||||
raise HTTPException(status_code=500, detail=f"Failed to get procurement trends: {str(e)}")
|
||||
@@ -94,11 +94,13 @@ service.setup_standard_endpoints()
|
||||
from app.api.procurement_plans import router as procurement_plans_router
|
||||
from app.api.purchase_orders import router as purchase_orders_router
|
||||
from app.api import replenishment # Enhanced Replenishment Planning Routes
|
||||
from app.api import analytics # Procurement Analytics Routes
|
||||
from app.api import internal_demo
|
||||
|
||||
service.add_router(procurement_plans_router)
|
||||
service.add_router(purchase_orders_router)
|
||||
service.add_router(replenishment.router, prefix="/api/v1/tenants/{tenant_id}", tags=["replenishment"])
|
||||
service.add_router(analytics.router, tags=["analytics"]) # RouteBuilder already includes full path
|
||||
service.add_router(internal_demo.router)
|
||||
|
||||
|
||||
|
||||
@@ -90,6 +90,30 @@ class ProcurementPlanRepository(BaseRepository):
|
||||
result = await self.db.execute(stmt)
|
||||
return result.scalars().all()
|
||||
|
||||
async def get_plans_by_tenant(
|
||||
self,
|
||||
tenant_id: uuid.UUID,
|
||||
start_date: Optional[datetime] = None,
|
||||
end_date: Optional[datetime] = None
|
||||
) -> List[ProcurementPlan]:
|
||||
"""Get all procurement plans for a tenant with optional date filters"""
|
||||
conditions = [ProcurementPlan.tenant_id == tenant_id]
|
||||
|
||||
if start_date:
|
||||
conditions.append(ProcurementPlan.created_at >= start_date)
|
||||
if end_date:
|
||||
conditions.append(ProcurementPlan.created_at <= end_date)
|
||||
|
||||
stmt = (
|
||||
select(ProcurementPlan)
|
||||
.where(and_(*conditions))
|
||||
.order_by(desc(ProcurementPlan.created_at))
|
||||
.options(selectinload(ProcurementPlan.requirements))
|
||||
)
|
||||
|
||||
result = await self.db.execute(stmt)
|
||||
return result.scalars().all()
|
||||
|
||||
async def update_plan(self, plan_id: uuid.UUID, tenant_id: uuid.UUID, updates: Dict[str, Any]) -> Optional[ProcurementPlan]:
|
||||
"""Update procurement plan"""
|
||||
plan = await self.get_plan_by_id(plan_id, tenant_id)
|
||||
@@ -204,3 +228,27 @@ class ProcurementRequirementRepository(BaseRepository):
|
||||
count = result.scalar() or 0
|
||||
|
||||
return f"REQ-{count + 1:05d}"
|
||||
|
||||
async def get_requirements_by_tenant(
|
||||
self,
|
||||
tenant_id: uuid.UUID,
|
||||
start_date: Optional[datetime] = None,
|
||||
end_date: Optional[datetime] = None
|
||||
) -> List[ProcurementRequirement]:
|
||||
"""Get all procurement requirements for a tenant with optional date filters"""
|
||||
conditions = [ProcurementPlan.tenant_id == tenant_id]
|
||||
|
||||
if start_date:
|
||||
conditions.append(ProcurementRequirement.created_at >= start_date)
|
||||
if end_date:
|
||||
conditions.append(ProcurementRequirement.created_at <= end_date)
|
||||
|
||||
stmt = (
|
||||
select(ProcurementRequirement)
|
||||
.join(ProcurementPlan)
|
||||
.where(and_(*conditions))
|
||||
.order_by(desc(ProcurementRequirement.created_at))
|
||||
)
|
||||
|
||||
result = await self.db.execute(stmt)
|
||||
return result.scalars().all()
|
||||
|
||||
@@ -100,15 +100,19 @@ class ProcurementService:
|
||||
|
||||
# Initialize Recipe Explosion Service
|
||||
self.recipe_explosion_service = RecipeExplosionService(
|
||||
config=config,
|
||||
recipes_client=self.recipes_client,
|
||||
inventory_client=self.inventory_client
|
||||
)
|
||||
|
||||
# Initialize Smart Calculator (keep for backward compatibility)
|
||||
self.smart_calculator = SmartProcurementCalculator(
|
||||
inventory_client=self.inventory_client,
|
||||
forecast_client=self.forecast_client
|
||||
procurement_settings={
|
||||
'use_reorder_rules': True,
|
||||
'economic_rounding': True,
|
||||
'respect_storage_limits': True,
|
||||
'use_supplier_minimums': True,
|
||||
'optimize_price_tiers': True
|
||||
}
|
||||
)
|
||||
|
||||
# NEW: Initialize advanced planning services
|
||||
@@ -351,6 +355,325 @@ class ProcurementService:
|
||||
errors=[str(e)]
|
||||
)
|
||||
|
||||
async def get_procurement_analytics(self, tenant_id: uuid.UUID, start_date: Optional[datetime] = None, end_date: Optional[datetime] = None):
|
||||
"""
|
||||
Get procurement analytics dashboard data with real supplier data and trends
|
||||
"""
|
||||
try:
|
||||
logger.info("Retrieving procurement analytics", tenant_id=tenant_id)
|
||||
|
||||
# Set default date range if not provided
|
||||
if not end_date:
|
||||
end_date = datetime.now()
|
||||
if not start_date:
|
||||
start_date = end_date - timedelta(days=30)
|
||||
|
||||
# Get procurement plans summary
|
||||
plans = await self.plan_repo.get_plans_by_tenant(tenant_id, start_date, end_date)
|
||||
total_plans = len(plans)
|
||||
|
||||
# Calculate summary metrics
|
||||
total_estimated_cost = sum(float(plan.total_estimated_cost or 0) for plan in plans)
|
||||
total_approved_cost = sum(float(plan.total_approved_cost or 0) for plan in plans)
|
||||
cost_variance = total_approved_cost - total_estimated_cost
|
||||
|
||||
# Get requirements for performance metrics
|
||||
requirements = await self.requirement_repo.get_requirements_by_tenant(tenant_id, start_date, end_date)
|
||||
|
||||
# Calculate performance metrics
|
||||
fulfilled_requirements = [r for r in requirements if r.status == 'received']
|
||||
on_time_deliveries = [r for r in fulfilled_requirements if r.delivery_status == 'delivered']
|
||||
|
||||
fulfillment_rate = len(fulfilled_requirements) / len(requirements) if requirements else 0
|
||||
on_time_rate = len(on_time_deliveries) / len(fulfilled_requirements) if fulfilled_requirements else 0
|
||||
|
||||
# Calculate cost accuracy
|
||||
cost_accuracy = 0
|
||||
if requirements:
|
||||
cost_variance_items = [r for r in requirements if r.estimated_total_cost and r.estimated_total_cost != 0]
|
||||
if cost_variance_items:
|
||||
cost_accuracy = 1.0 - (sum(
|
||||
abs(float(r.estimated_total_cost or 0) - float(r.approved_cost or 0)) / float(r.estimated_total_cost or 1)
|
||||
for r in cost_variance_items
|
||||
) / len(cost_variance_items))
|
||||
|
||||
# ============================================================
|
||||
# TREND CALCULATIONS (7-day comparison)
|
||||
# ============================================================
|
||||
trend_start = end_date - timedelta(days=7)
|
||||
previous_period_end = trend_start
|
||||
previous_period_start = previous_period_end - timedelta(days=7)
|
||||
|
||||
# Get previous period data
|
||||
prev_requirements = await self.requirement_repo.get_requirements_by_tenant(
|
||||
tenant_id, previous_period_start, previous_period_end
|
||||
)
|
||||
|
||||
# Calculate previous period metrics
|
||||
prev_fulfilled = [r for r in prev_requirements if r.status == 'received']
|
||||
prev_on_time = [r for r in prev_fulfilled if r.delivery_status == 'delivered']
|
||||
|
||||
prev_fulfillment_rate = len(prev_fulfilled) / len(prev_requirements) if prev_requirements else 0
|
||||
prev_on_time_rate = len(prev_on_time) / len(prev_fulfilled) if prev_fulfilled else 0
|
||||
|
||||
prev_cost_accuracy = 0
|
||||
if prev_requirements:
|
||||
prev_cost_items = [r for r in prev_requirements if r.estimated_total_cost and r.estimated_total_cost != 0]
|
||||
if prev_cost_items:
|
||||
prev_cost_accuracy = 1.0 - (sum(
|
||||
abs(float(r.estimated_total_cost or 0) - float(r.approved_cost or 0)) / float(r.estimated_total_cost or 1)
|
||||
for r in prev_cost_items
|
||||
) / len(prev_cost_items))
|
||||
|
||||
# Calculate trend percentages
|
||||
fulfillment_trend = self._calculate_trend_percentage(fulfillment_rate, prev_fulfillment_rate)
|
||||
on_time_trend = self._calculate_trend_percentage(on_time_rate, prev_on_time_rate)
|
||||
cost_variance_trend = self._calculate_trend_percentage(cost_accuracy, prev_cost_accuracy)
|
||||
|
||||
# Plan status distribution
|
||||
status_counts = {}
|
||||
for plan in plans:
|
||||
status = plan.status
|
||||
status_counts[status] = status_counts.get(status, 0) + 1
|
||||
|
||||
plan_status_distribution = [
|
||||
{"status": status, "count": count}
|
||||
for status, count in status_counts.items()
|
||||
]
|
||||
|
||||
# ============================================================
|
||||
# CRITICAL REQUIREMENTS with REAL INVENTORY DATA
|
||||
# ============================================================
|
||||
try:
|
||||
inventory_items = await self.inventory_client.get_all_ingredients(str(tenant_id))
|
||||
inventory_map = {str(item.get('id')): item for item in inventory_items}
|
||||
|
||||
low_stock_count = 0
|
||||
for req in requirements:
|
||||
ingredient_id = str(req.ingredient_id)
|
||||
if ingredient_id in inventory_map:
|
||||
inv_item = inventory_map[ingredient_id]
|
||||
current_stock = float(inv_item.get('quantity_available', 0))
|
||||
reorder_point = float(inv_item.get('reorder_point', 0))
|
||||
if current_stock <= reorder_point:
|
||||
low_stock_count += 1
|
||||
except Exception as e:
|
||||
logger.warning("Failed to get inventory data for critical requirements", error=str(e))
|
||||
low_stock_count = len([r for r in requirements if r.priority == 'high'])
|
||||
|
||||
critical_requirements = {
|
||||
"low_stock": low_stock_count,
|
||||
"overdue": len([r for r in requirements if r.status == 'pending' and r.required_by_date < datetime.now().date()]),
|
||||
"high_priority": len([r for r in requirements if r.priority == 'high'])
|
||||
}
|
||||
|
||||
# Recent plans
|
||||
recent_plans = []
|
||||
for plan in sorted(plans, key=lambda x: x.created_at, reverse=True)[:5]:
|
||||
recent_plans.append({
|
||||
"id": str(plan.id),
|
||||
"plan_number": plan.plan_number,
|
||||
"plan_date": plan.plan_date.isoformat() if plan.plan_date else None,
|
||||
"status": plan.status,
|
||||
"total_requirements": plan.total_requirements or 0,
|
||||
"total_estimated_cost": float(plan.total_estimated_cost or 0),
|
||||
"created_at": plan.created_at.isoformat() if plan.created_at else None
|
||||
})
|
||||
|
||||
# ============================================================
|
||||
# SUPPLIER PERFORMANCE with REAL SUPPLIER DATA
|
||||
# ============================================================
|
||||
supplier_performance = []
|
||||
supplier_reqs = {}
|
||||
for req in requirements:
|
||||
if req.preferred_supplier_id:
|
||||
supplier_id = str(req.preferred_supplier_id)
|
||||
if supplier_id not in supplier_reqs:
|
||||
supplier_reqs[supplier_id] = []
|
||||
supplier_reqs[supplier_id].append(req)
|
||||
|
||||
# Fetch real supplier data
|
||||
try:
|
||||
suppliers_data = await self.suppliers_client.get_all_suppliers(str(tenant_id))
|
||||
suppliers_map = {str(s.get('id')): s for s in suppliers_data}
|
||||
|
||||
for supplier_id, reqs in supplier_reqs.items():
|
||||
fulfilled = len([r for r in reqs if r.status == 'received'])
|
||||
on_time = len([r for r in reqs if r.delivery_status == 'delivered'])
|
||||
|
||||
# Get real supplier info
|
||||
supplier_info = suppliers_map.get(supplier_id, {})
|
||||
supplier_name = supplier_info.get('name', f'Unknown Supplier')
|
||||
|
||||
# Use real quality rating from supplier data
|
||||
quality_score = supplier_info.get('quality_rating', 0)
|
||||
delivery_rating = supplier_info.get('delivery_rating', 0)
|
||||
|
||||
supplier_performance.append({
|
||||
"id": supplier_id,
|
||||
"name": supplier_name,
|
||||
"total_orders": len(reqs),
|
||||
"fulfillment_rate": fulfilled / len(reqs) if reqs else 0,
|
||||
"on_time_rate": on_time / fulfilled if fulfilled else 0,
|
||||
"quality_score": quality_score
|
||||
})
|
||||
except Exception as e:
|
||||
logger.warning("Failed to get supplier data, using fallback", error=str(e))
|
||||
for supplier_id, reqs in supplier_reqs.items():
|
||||
fulfilled = len([r for r in reqs if r.status == 'received'])
|
||||
on_time = len([r for r in reqs if r.delivery_status == 'delivered'])
|
||||
supplier_performance.append({
|
||||
"id": supplier_id,
|
||||
"name": f"Supplier {supplier_id[:8]}...",
|
||||
"total_orders": len(reqs),
|
||||
"fulfillment_rate": fulfilled / len(reqs) if reqs else 0,
|
||||
"on_time_rate": on_time / fulfilled if fulfilled else 0,
|
||||
"quality_score": 0
|
||||
})
|
||||
|
||||
# Cost by category
|
||||
cost_by_category = []
|
||||
category_costs = {}
|
||||
for req in requirements:
|
||||
category = req.product_category or "Uncategorized"
|
||||
category_costs[category] = category_costs.get(category, 0) + float(req.estimated_total_cost or 0)
|
||||
|
||||
for category, amount in category_costs.items():
|
||||
cost_by_category.append({
|
||||
"name": category,
|
||||
"amount": amount
|
||||
})
|
||||
|
||||
# Quality metrics
|
||||
quality_reqs = [r for r in requirements if hasattr(r, 'quality_rating') and r.quality_rating]
|
||||
avg_quality = sum(r.quality_rating for r in quality_reqs) / len(quality_reqs) if quality_reqs else 0
|
||||
high_quality_count = len([r for r in quality_reqs if r.quality_rating >= 4.0])
|
||||
low_quality_count = len([r for r in quality_reqs if r.quality_rating <= 2.0])
|
||||
|
||||
analytics_data = {
|
||||
"summary": {
|
||||
"total_plans": total_plans,
|
||||
"total_estimated_cost": total_estimated_cost,
|
||||
"total_approved_cost": total_approved_cost,
|
||||
"cost_variance": cost_variance
|
||||
},
|
||||
"performance_metrics": {
|
||||
"average_fulfillment_rate": fulfillment_rate,
|
||||
"average_on_time_delivery": on_time_rate,
|
||||
"cost_accuracy": cost_accuracy,
|
||||
"supplier_performance": avg_quality if quality_reqs else 0,
|
||||
"fulfillment_trend": fulfillment_trend,
|
||||
"on_time_trend": on_time_trend,
|
||||
"cost_variance_trend": cost_variance_trend
|
||||
},
|
||||
"plan_status_distribution": plan_status_distribution,
|
||||
"critical_requirements": critical_requirements,
|
||||
"recent_plans": recent_plans,
|
||||
"supplier_performance": supplier_performance,
|
||||
"cost_by_category": cost_by_category,
|
||||
"quality_metrics": {
|
||||
"avg_score": avg_quality,
|
||||
"high_quality_count": high_quality_count,
|
||||
"low_quality_count": low_quality_count
|
||||
}
|
||||
}
|
||||
|
||||
return analytics_data
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to get procurement analytics", error=str(e), tenant_id=tenant_id)
|
||||
raise
|
||||
|
||||
def _calculate_trend_percentage(self, current_value: float, previous_value: float) -> float:
|
||||
"""
|
||||
Calculate percentage change between current and previous values
|
||||
Returns percentage change (e.g., 0.05 for 5% increase, -0.03 for 3% decrease)
|
||||
"""
|
||||
if previous_value == 0:
|
||||
return 0.0 if current_value == 0 else 1.0
|
||||
|
||||
change = ((current_value - previous_value) / previous_value)
|
||||
return round(change, 4)
|
||||
|
||||
async def get_procurement_trends(self, tenant_id: uuid.UUID, days: int = 7):
|
||||
"""
|
||||
Get time-series procurement trends for charts (last N days)
|
||||
Returns daily metrics for performance and quality trends
|
||||
"""
|
||||
try:
|
||||
logger.info("Retrieving procurement trends", tenant_id=tenant_id, days=days)
|
||||
|
||||
end_date = datetime.now()
|
||||
start_date = end_date - timedelta(days=days)
|
||||
|
||||
# Get requirements for the period
|
||||
requirements = await self.requirement_repo.get_requirements_by_tenant(tenant_id, start_date, end_date)
|
||||
|
||||
# Group requirements by day
|
||||
daily_data = {}
|
||||
for day_offset in range(days):
|
||||
day_date = (start_date + timedelta(days=day_offset)).date()
|
||||
daily_data[day_date] = {
|
||||
'date': day_date.isoformat(),
|
||||
'requirements': [],
|
||||
'fulfillment_rate': 0,
|
||||
'on_time_rate': 0,
|
||||
'quality_score': 0
|
||||
}
|
||||
|
||||
# Assign requirements to days based on creation date
|
||||
for req in requirements:
|
||||
req_date = req.created_at.date() if req.created_at else None
|
||||
if req_date and req_date in daily_data:
|
||||
daily_data[req_date]['requirements'].append(req)
|
||||
|
||||
# Calculate daily metrics
|
||||
performance_trend = []
|
||||
quality_trend = []
|
||||
|
||||
for day_date in sorted(daily_data.keys()):
|
||||
day_reqs = daily_data[day_date]['requirements']
|
||||
|
||||
if day_reqs:
|
||||
# Calculate fulfillment rate
|
||||
fulfilled = [r for r in day_reqs if r.status == 'received']
|
||||
fulfillment_rate = len(fulfilled) / len(day_reqs) if day_reqs else 0
|
||||
|
||||
# Calculate on-time rate
|
||||
on_time = [r for r in fulfilled if r.delivery_status == 'delivered']
|
||||
on_time_rate = len(on_time) / len(fulfilled) if fulfilled else 0
|
||||
|
||||
# Calculate quality score
|
||||
quality_reqs = [r for r in day_reqs if hasattr(r, 'quality_rating') and r.quality_rating]
|
||||
avg_quality = sum(r.quality_rating for r in quality_reqs) / len(quality_reqs) if quality_reqs else 0
|
||||
else:
|
||||
fulfillment_rate = 0
|
||||
on_time_rate = 0
|
||||
avg_quality = 0
|
||||
|
||||
performance_trend.append({
|
||||
'date': day_date.isoformat(),
|
||||
'fulfillment_rate': round(fulfillment_rate, 4),
|
||||
'on_time_rate': round(on_time_rate, 4)
|
||||
})
|
||||
|
||||
quality_trend.append({
|
||||
'date': day_date.isoformat(),
|
||||
'quality_score': round(avg_quality, 2)
|
||||
})
|
||||
|
||||
return {
|
||||
'performance_trend': performance_trend,
|
||||
'quality_trend': quality_trend,
|
||||
'period_days': days,
|
||||
'start_date': start_date.date().isoformat(),
|
||||
'end_date': end_date.date().isoformat()
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to get procurement trends", error=str(e), tenant_id=tenant_id)
|
||||
raise
|
||||
|
||||
# ============================================================
|
||||
# Helper Methods
|
||||
# ============================================================
|
||||
|
||||
@@ -84,7 +84,30 @@ class Tenant(Base):
|
||||
return f"<Tenant(id={self.id}, name={self.name})>"
|
||||
|
||||
class TenantMember(Base):
|
||||
"""Tenant membership model for team access"""
|
||||
"""
|
||||
Tenant membership model for team access.
|
||||
|
||||
This model represents TENANT-SPECIFIC roles, which are distinct from global user roles.
|
||||
|
||||
TENANT ROLES (stored here):
|
||||
- owner: Full control of the tenant, can transfer ownership, manage all aspects
|
||||
- admin: Tenant administrator, can manage team members and most operations
|
||||
- member: Standard team member, regular operational access
|
||||
- viewer: Read-only observer, view-only access to tenant data
|
||||
|
||||
ROLE MAPPING TO GLOBAL ROLES:
|
||||
When users are created through tenant management (pilot phase), their tenant role
|
||||
is mapped to a global user role in the Auth service:
|
||||
- tenant 'admin' → global 'admin' (system-wide admin access)
|
||||
- tenant 'member' → global 'manager' (management-level access)
|
||||
- tenant 'viewer' → global 'user' (basic user access)
|
||||
- tenant 'owner' → No automatic global role (owner is tenant-specific)
|
||||
|
||||
This mapping is implemented in app/api/tenant_members.py lines 68-76.
|
||||
|
||||
Note: user_id is a cross-service reference (no FK) to avoid circular dependencies.
|
||||
User enrichment is handled at the service layer via Auth service calls.
|
||||
"""
|
||||
__tablename__ = "tenant_members"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
@@ -92,7 +115,8 @@ class TenantMember(Base):
|
||||
user_id = Column(UUID(as_uuid=True), nullable=False, index=True) # No FK - cross-service reference
|
||||
|
||||
# Role and permissions specific to this tenant
|
||||
role = Column(String(50), default="member") # owner, admin, member, viewer
|
||||
# Valid values: 'owner', 'admin', 'member', 'viewer'
|
||||
role = Column(String(50), default="member")
|
||||
permissions = Column(Text) # JSON string of permissions
|
||||
|
||||
# Status
|
||||
|
||||
6
todo.md
Normal file
6
todo.md
Normal file
@@ -0,0 +1,6 @@
|
||||
# Analytics API Fix Todo List
|
||||
|
||||
- [x] Identify current frontend API calls that need to be updated
|
||||
- [ ] Update gateway routing to properly handle analytics requests
|
||||
- [ ] Verify backend procurement service analytics endpoint is working
|
||||
- [ ] Test the complete API flow
|
||||
Reference in New Issue
Block a user