Add procurement management logic

This commit is contained in:
Urtzi Alfaro
2025-08-23 19:47:08 +02:00
parent 62ff755f25
commit 5077a45a25
22 changed files with 4011 additions and 79 deletions

View File

@@ -13,6 +13,21 @@ export { useNotification } from './useNotification';
export { useOnboarding, useOnboardingStep } from './useOnboarding';
export { useInventory, useInventoryDashboard, useInventoryItem, useInventoryProducts } from './useInventory';
export { useRecipes, useProduction } from './useRecipes';
export {
useCurrentProcurementPlan,
useProcurementPlanByDate,
useProcurementPlan,
useProcurementPlans,
usePlanRequirements,
useCriticalRequirements,
useProcurementDashboard,
useGenerateProcurementPlan,
useUpdatePlanStatus,
useTriggerDailyScheduler,
useProcurementHealth,
useProcurementPlanDashboard,
useProcurementPlanActions
} from './useProcurement';
// Import hooks for combined usage
import { useAuth } from './useAuth';

View File

@@ -0,0 +1,294 @@
// ================================================================
// frontend/src/api/hooks/useProcurement.ts
// ================================================================
/**
* React hooks for procurement planning functionality
*/
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { procurementService } from '../services/procurement.service';
import type {
ProcurementPlan,
GeneratePlanRequest,
GeneratePlanResponse,
DashboardData,
ProcurementRequirement,
PaginatedProcurementPlans
} from '../types/procurement';
// ================================================================
// QUERY KEYS
// ================================================================
export const procurementKeys = {
all: ['procurement'] as const,
plans: () => [...procurementKeys.all, 'plans'] as const,
plan: (id: string) => [...procurementKeys.plans(), id] as const,
currentPlan: () => [...procurementKeys.plans(), 'current'] as const,
planByDate: (date: string) => [...procurementKeys.plans(), 'date', date] as const,
plansList: (filters?: any) => [...procurementKeys.plans(), 'list', filters] as const,
requirements: () => [...procurementKeys.all, 'requirements'] as const,
planRequirements: (planId: string) => [...procurementKeys.requirements(), 'plan', planId] as const,
criticalRequirements: () => [...procurementKeys.requirements(), 'critical'] as const,
dashboard: () => [...procurementKeys.all, 'dashboard'] as const,
};
// ================================================================
// PROCUREMENT PLAN HOOKS
// ================================================================
/**
* Hook to fetch the current day's procurement plan
*/
export function useCurrentProcurementPlan() {
return useQuery({
queryKey: procurementKeys.currentPlan(),
queryFn: () => procurementService.getCurrentPlan(),
staleTime: 5 * 60 * 1000, // 5 minutes
refetchInterval: 10 * 60 * 1000, // Refetch every 10 minutes
});
}
/**
* Hook to fetch procurement plan by date
*/
export function useProcurementPlanByDate(date: string, enabled = true) {
return useQuery({
queryKey: procurementKeys.planByDate(date),
queryFn: () => procurementService.getPlanByDate(date),
enabled: enabled && !!date,
staleTime: 30 * 60 * 1000, // 30 minutes for historical data
});
}
/**
* Hook to fetch procurement plan by ID
*/
export function useProcurementPlan(planId: string, enabled = true) {
return useQuery({
queryKey: procurementKeys.plan(planId),
queryFn: () => procurementService.getPlanById(planId),
enabled: enabled && !!planId,
staleTime: 10 * 60 * 1000, // 10 minutes
});
}
/**
* Hook to fetch paginated list of procurement plans
*/
export function useProcurementPlans(params?: {
status?: string;
startDate?: string;
endDate?: string;
limit?: number;
offset?: number;
}) {
return useQuery({
queryKey: procurementKeys.plansList(params),
queryFn: () => procurementService.listPlans(params),
staleTime: 5 * 60 * 1000, // 5 minutes
});
}
// ================================================================
// REQUIREMENTS HOOKS
// ================================================================
/**
* Hook to fetch requirements for a specific plan
*/
export function usePlanRequirements(
planId: string,
filters?: {
status?: string;
priority?: string;
},
enabled = true
) {
return useQuery({
queryKey: procurementKeys.planRequirements(planId),
queryFn: () => procurementService.getPlanRequirements(planId, filters),
enabled: enabled && !!planId,
staleTime: 5 * 60 * 1000, // 5 minutes
});
}
/**
* Hook to fetch critical requirements across all plans
*/
export function useCriticalRequirements() {
return useQuery({
queryKey: procurementKeys.criticalRequirements(),
queryFn: () => procurementService.getCriticalRequirements(),
staleTime: 2 * 60 * 1000, // 2 minutes for critical data
refetchInterval: 5 * 60 * 1000, // Refetch every 5 minutes
});
}
// ================================================================
// DASHBOARD HOOKS
// ================================================================
/**
* Hook to fetch procurement dashboard data
*/
export function useProcurementDashboard() {
return useQuery({
queryKey: procurementKeys.dashboard(),
queryFn: () => procurementService.getDashboardData(),
staleTime: 2 * 60 * 1000, // 2 minutes
refetchInterval: 5 * 60 * 1000, // Refetch every 5 minutes
});
}
// ================================================================
// MUTATION HOOKS
// ================================================================
/**
* Hook to generate a new procurement plan
*/
export function useGenerateProcurementPlan() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (request: GeneratePlanRequest) =>
procurementService.generatePlan(request),
onSuccess: (data: GeneratePlanResponse) => {
// Invalidate relevant queries
queryClient.invalidateQueries({ queryKey: procurementKeys.plans() });
queryClient.invalidateQueries({ queryKey: procurementKeys.dashboard() });
// If plan was generated successfully, update the cache
if (data.success && data.plan) {
queryClient.setQueryData(
procurementKeys.plan(data.plan.id),
data.plan
);
// Update current plan cache if this is today's plan
const today = new Date().toISOString().split('T')[0];
if (data.plan.plan_date === today) {
queryClient.setQueryData(
procurementKeys.currentPlan(),
data.plan
);
}
}
},
});
}
/**
* Hook to update procurement plan status
*/
export function useUpdatePlanStatus() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ planId, status }: { planId: string; status: string }) =>
procurementService.updatePlanStatus(planId, status),
onSuccess: (updatedPlan: ProcurementPlan) => {
// Update the specific plan in cache
queryClient.setQueryData(
procurementKeys.plan(updatedPlan.id),
updatedPlan
);
// Update current plan if this is the current plan
const today = new Date().toISOString().split('T')[0];
if (updatedPlan.plan_date === today) {
queryClient.setQueryData(
procurementKeys.currentPlan(),
updatedPlan
);
}
// Invalidate lists to ensure they're refreshed
queryClient.invalidateQueries({ queryKey: procurementKeys.plansList() });
queryClient.invalidateQueries({ queryKey: procurementKeys.dashboard() });
},
});
}
/**
* Hook to trigger the daily scheduler manually
*/
export function useTriggerDailyScheduler() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: () => procurementService.triggerDailyScheduler(),
onSuccess: () => {
// Invalidate all procurement data
queryClient.invalidateQueries({ queryKey: procurementKeys.all });
},
});
}
// ================================================================
// UTILITY HOOKS
// ================================================================
/**
* Hook to check procurement service health
*/
export function useProcurementHealth() {
return useQuery({
queryKey: [...procurementKeys.all, 'health'],
queryFn: () => procurementService.healthCheck(),
staleTime: 60 * 1000, // 1 minute
refetchInterval: 5 * 60 * 1000, // Check every 5 minutes
});
}
// ================================================================
// COMBINED HOOKS
// ================================================================
/**
* Combined hook for procurement plan dashboard
* Fetches current plan, dashboard data, and critical requirements
*/
export function useProcurementPlanDashboard() {
const currentPlan = useCurrentProcurementPlan();
const dashboard = useProcurementDashboard();
const criticalRequirements = useCriticalRequirements();
const health = useProcurementHealth();
return {
currentPlan,
dashboard,
criticalRequirements,
health,
isLoading: currentPlan.isLoading || dashboard.isLoading,
error: currentPlan.error || dashboard.error || criticalRequirements.error,
refetchAll: () => {
currentPlan.refetch();
dashboard.refetch();
criticalRequirements.refetch();
health.refetch();
},
};
}
/**
* Hook for managing procurement plan lifecycle
*/
export function useProcurementPlanActions() {
const generatePlan = useGenerateProcurementPlan();
const updateStatus = useUpdatePlanStatus();
const triggerScheduler = useTriggerDailyScheduler();
return {
generatePlan: generatePlan.mutate,
updateStatus: updateStatus.mutate,
triggerScheduler: triggerScheduler.mutate,
isGenerating: generatePlan.isPending,
isUpdating: updateStatus.isPending,
isTriggering: triggerScheduler.isPending,
generateError: generatePlan.error,
updateError: updateStatus.error,
triggerError: triggerScheduler.error,
};
}

View File

@@ -18,6 +18,7 @@ import { RecipesService } from './recipes.service';
import { ProductionService } from './production.service';
import { OrdersService } from './orders.service';
import { SuppliersService } from './suppliers.service';
import { ProcurementService } from './procurement.service';
// Create service instances
export const authService = new AuthService();
@@ -33,6 +34,7 @@ export const recipesService = new RecipesService();
export const productionService = new ProductionService();
export const ordersService = new OrdersService();
export const suppliersService = new SuppliersService();
export const procurementService = new ProcurementService();
// Export the classes as well
export {
@@ -48,7 +50,8 @@ export {
RecipesService,
ProductionService,
OrdersService,
SuppliersService
SuppliersService,
ProcurementService
};
// Import base client
@@ -73,6 +76,7 @@ export const api = {
production: productionService,
orders: ordersService,
suppliers: suppliersService,
procurement: procurementService,
} as const;
// Service status checking
@@ -98,6 +102,7 @@ export class HealthService {
{ name: 'Suppliers', endpoint: '/suppliers/health' },
{ name: 'Forecasting', endpoint: '/forecasting/health' },
{ name: 'Notification', endpoint: '/notifications/health' },
{ name: 'Procurement', endpoint: '/procurement-plans/health' },
];
const healthChecks = await Promise.allSettled(

View File

@@ -0,0 +1,135 @@
// ================================================================
// frontend/src/api/services/procurement.service.ts
// ================================================================
/**
* Procurement Service - API client for procurement planning endpoints
*/
import { ApiClient } from '../client';
import type {
ProcurementPlan,
GeneratePlanRequest,
GeneratePlanResponse,
DashboardData,
ProcurementRequirement,
PaginatedProcurementPlans
} from '../types/procurement';
export class ProcurementService {
constructor(private client: ApiClient) {}
// ================================================================
// PROCUREMENT PLAN OPERATIONS
// ================================================================
/**
* Get the procurement plan for the current day
*/
async getCurrentPlan(): Promise<ProcurementPlan | null> {
return this.client.get('/procurement-plans/current');
}
/**
* Get procurement plan for a specific date
*/
async getPlanByDate(date: string): Promise<ProcurementPlan | null> {
return this.client.get(`/procurement-plans/${date}`);
}
/**
* Get procurement plan by ID
*/
async getPlanById(planId: string): Promise<ProcurementPlan | null> {
return this.client.get(`/procurement-plans/id/${planId}`);
}
/**
* List procurement plans with optional filters
*/
async listPlans(params?: {
status?: string;
startDate?: string;
endDate?: string;
limit?: number;
offset?: number;
}): Promise<PaginatedProcurementPlans> {
return this.client.get('/procurement-plans/', { params });
}
/**
* Generate a new procurement plan
*/
async generatePlan(request: GeneratePlanRequest): Promise<GeneratePlanResponse> {
return this.client.post('/procurement-plans/generate', request);
}
/**
* Update procurement plan status
*/
async updatePlanStatus(planId: string, status: string): Promise<ProcurementPlan> {
return this.client.put(`/procurement-plans/${planId}/status`, null, {
params: { status }
});
}
// ================================================================
// REQUIREMENTS OPERATIONS
// ================================================================
/**
* Get all requirements for a specific procurement plan
*/
async getPlanRequirements(
planId: string,
params?: {
status?: string;
priority?: string;
}
): Promise<ProcurementRequirement[]> {
return this.client.get(`/procurement-plans/${planId}/requirements`, { params });
}
/**
* Get all critical priority requirements
*/
async getCriticalRequirements(): Promise<ProcurementRequirement[]> {
return this.client.get('/procurement-plans/requirements/critical');
}
// ================================================================
// DASHBOARD OPERATIONS
// ================================================================
/**
* Get procurement dashboard data
*/
async getDashboardData(): Promise<DashboardData | null> {
return this.client.get('/procurement-plans/dashboard/data');
}
// ================================================================
// UTILITY OPERATIONS
// ================================================================
/**
* Manually trigger the daily scheduler
*/
async triggerDailyScheduler(): Promise<{ success: boolean; message: string; tenant_id: string }> {
return this.client.post('/procurement-plans/scheduler/trigger');
}
/**
* Health check for procurement service
*/
async healthCheck(): Promise<{
status: string;
service: string;
procurement_enabled: boolean;
timestamp: string;
}> {
return this.client.get('/procurement-plans/health');
}
}
// Export singleton instance
export const procurementService = new ProcurementService(new ApiClient());

View File

@@ -10,4 +10,5 @@ export * from './tenant';
export * from './data';
export * from './training';
export * from './forecasting';
export * from './notification';
export * from './notification';
export * from './procurement';

View File

@@ -0,0 +1,330 @@
// ================================================================
// frontend/src/api/types/procurement.ts
// ================================================================
/**
* TypeScript types for procurement planning API
*/
// ================================================================
// BASE TYPES
// ================================================================
export interface ProcurementRequirement {
id: string;
plan_id: string;
requirement_number: string;
// Product information
product_id: string;
product_name: string;
product_sku?: string;
product_category?: string;
product_type: string;
// Quantity requirements
required_quantity: number;
unit_of_measure: string;
safety_stock_quantity: number;
total_quantity_needed: number;
// Current inventory situation
current_stock_level: number;
reserved_stock: number;
available_stock: number;
net_requirement: number;
// Demand breakdown
order_demand: number;
production_demand: number;
forecast_demand: number;
buffer_demand: number;
// Supplier information
preferred_supplier_id?: string;
backup_supplier_id?: string;
supplier_name?: string;
supplier_lead_time_days?: number;
minimum_order_quantity?: number;
// Pricing
estimated_unit_cost?: number;
estimated_total_cost?: number;
last_purchase_cost?: number;
cost_variance: number;
// Timing
required_by_date: string;
lead_time_buffer_days: number;
suggested_order_date: string;
latest_order_date: string;
// Status and priority
status: string;
priority: string;
risk_level: string;
// Purchase tracking
purchase_order_id?: string;
purchase_order_number?: string;
ordered_quantity: number;
ordered_at?: string;
// Delivery tracking
expected_delivery_date?: string;
actual_delivery_date?: string;
received_quantity: number;
delivery_status: string;
// Performance metrics
fulfillment_rate?: number;
on_time_delivery?: boolean;
quality_rating?: number;
// Approval
approved_quantity?: number;
approved_cost?: number;
approved_at?: string;
approved_by?: string;
// Additional info
special_requirements?: string;
storage_requirements?: string;
shelf_life_days?: number;
quality_specifications?: Record<string, any>;
procurement_notes?: string;
}
export interface ProcurementPlan {
id: string;
tenant_id: string;
plan_number: string;
// Plan scope and timing
plan_date: string;
plan_period_start: string;
plan_period_end: string;
planning_horizon_days: number;
// Plan status and lifecycle
status: string;
plan_type: string;
priority: string;
// Business context
business_model?: string;
procurement_strategy: string;
// Plan totals and summary
total_requirements: number;
total_estimated_cost: number;
total_approved_cost: number;
cost_variance: number;
// Demand analysis
total_demand_orders: number;
total_demand_quantity: number;
total_production_requirements: number;
safety_stock_buffer: number;
// Supplier coordination
primary_suppliers_count: number;
backup_suppliers_count: number;
supplier_diversification_score?: number;
// Risk assessment
supply_risk_level: string;
demand_forecast_confidence?: number;
seasonality_adjustment: number;
// Execution tracking
approved_at?: string;
approved_by?: string;
execution_started_at?: string;
execution_completed_at?: string;
// Performance metrics
fulfillment_rate?: number;
on_time_delivery_rate?: number;
cost_accuracy?: number;
quality_score?: number;
// Metadata
created_at: string;
updated_at: string;
created_by?: string;
updated_by?: string;
// Additional info
special_requirements?: string;
// Relationships
requirements: ProcurementRequirement[];
}
// ================================================================
// REQUEST TYPES
// ================================================================
export interface GeneratePlanRequest {
plan_date?: string;
force_regenerate?: boolean;
planning_horizon_days?: number;
include_safety_stock?: boolean;
safety_stock_percentage?: number;
}
export interface ForecastRequest {
target_date: string;
horizon_days?: number;
include_confidence_intervals?: boolean;
product_ids?: string[];
}
// ================================================================
// RESPONSE TYPES
// ================================================================
export interface GeneratePlanResponse {
success: boolean;
message: string;
plan?: ProcurementPlan;
warnings?: string[];
errors?: string[];
}
export interface PaginatedProcurementPlans {
plans: ProcurementPlan[];
total: number;
page: number;
limit: number;
has_more: boolean;
}
// ================================================================
// DASHBOARD TYPES
// ================================================================
export interface ProcurementSummary {
total_plans: number;
active_plans: number;
total_requirements: number;
pending_requirements: number;
critical_requirements: number;
total_estimated_cost: number;
total_approved_cost: number;
cost_variance: number;
average_fulfillment_rate?: number;
average_on_time_delivery?: number;
top_suppliers: Array<Record<string, any>>;
critical_items: Array<Record<string, any>>;
}
export interface DashboardData {
current_plan?: ProcurementPlan;
summary: ProcurementSummary;
upcoming_deliveries: Array<Record<string, any>>;
overdue_requirements: Array<Record<string, any>>;
low_stock_alerts: Array<Record<string, any>>;
performance_metrics: Record<string, any>;
}
// ================================================================
// FILTER AND SEARCH TYPES
// ================================================================
export interface ProcurementFilters {
status?: string[];
priority?: string[];
risk_level?: string[];
supplier_id?: string;
product_category?: string;
date_range?: {
start: string;
end: string;
};
}
export interface RequirementFilters {
status?: string[];
priority?: string[];
product_type?: string[];
overdue_only?: boolean;
critical_only?: boolean;
}
// ================================================================
// UI COMPONENT TYPES
// ================================================================
export interface ProcurementPlanCardProps {
plan: ProcurementPlan;
onViewDetails?: (planId: string) => void;
onUpdateStatus?: (planId: string, status: string) => void;
showActions?: boolean;
}
export interface RequirementCardProps {
requirement: ProcurementRequirement;
onViewDetails?: (requirementId: string) => void;
onUpdateStatus?: (requirementId: string, status: string) => void;
showSupplierInfo?: boolean;
}
export interface ProcurementDashboardProps {
showFilters?: boolean;
refreshInterval?: number;
onPlanGenerated?: (plan: ProcurementPlan) => void;
}
// ================================================================
// ENUMS
// ================================================================
export enum PlanStatus {
DRAFT = 'draft',
PENDING_APPROVAL = 'pending_approval',
APPROVED = 'approved',
IN_EXECUTION = 'in_execution',
COMPLETED = 'completed',
CANCELLED = 'cancelled'
}
export enum RequirementStatus {
PENDING = 'pending',
APPROVED = 'approved',
ORDERED = 'ordered',
PARTIALLY_RECEIVED = 'partially_received',
RECEIVED = 'received',
CANCELLED = 'cancelled'
}
export enum Priority {
CRITICAL = 'critical',
HIGH = 'high',
NORMAL = 'normal',
LOW = 'low'
}
export enum RiskLevel {
LOW = 'low',
MEDIUM = 'medium',
HIGH = 'high',
CRITICAL = 'critical'
}
export enum PlanType {
REGULAR = 'regular',
EMERGENCY = 'emergency',
SEASONAL = 'seasonal'
}
export enum ProductType {
INGREDIENT = 'ingredient',
PACKAGING = 'packaging',
SUPPLIES = 'supplies'
}

View File

@@ -0,0 +1,216 @@
// ================================================================
// frontend/src/components/procurement/CriticalRequirements.tsx
// ================================================================
/**
* Critical Requirements Component
* Displays urgent procurement requirements that need immediate attention
*/
import React from 'react';
import type { ProcurementRequirement } from '@/api/types/procurement';
import { Priority, RequirementStatus } from '@/api/types/procurement';
export interface CriticalRequirementsProps {
requirements: ProcurementRequirement[];
onViewDetails?: (requirementId: string) => void;
onUpdateStatus?: (requirementId: string, status: string) => void;
}
export const CriticalRequirements: React.FC<CriticalRequirementsProps> = ({
requirements,
onViewDetails,
onUpdateStatus,
}) => {
const formatCurrency = (amount: number | undefined) => {
if (!amount) return 'N/A';
return new Intl.NumberFormat('es-ES', {
style: 'currency',
currency: 'EUR'
}).format(amount);
};
const formatDate = (dateString: string) => {
const date = new Date(dateString);
const today = new Date();
const diffTime = date.getTime() - today.getTime();
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
if (diffDays < 0) {
return `Overdue by ${Math.abs(diffDays)} days`;
} else if (diffDays === 0) {
return 'Due today';
} else if (diffDays === 1) {
return 'Due tomorrow';
} else {
return `Due in ${diffDays} days`;
}
};
const getStatusColor = (status: string) => {
const colors = {
[RequirementStatus.PENDING]: 'bg-yellow-100 text-yellow-800',
[RequirementStatus.APPROVED]: 'bg-blue-100 text-blue-800',
[RequirementStatus.ORDERED]: 'bg-purple-100 text-purple-800',
[RequirementStatus.PARTIALLY_RECEIVED]: 'bg-orange-100 text-orange-800',
[RequirementStatus.RECEIVED]: 'bg-green-100 text-green-800',
[RequirementStatus.CANCELLED]: 'bg-red-100 text-red-800',
};
return colors[status as keyof typeof colors] || 'bg-gray-100 text-gray-800';
};
const getDueDateColor = (dateString: string) => {
const date = new Date(dateString);
const today = new Date();
const diffTime = date.getTime() - today.getTime();
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
if (diffDays < 0) return 'text-red-600 font-medium'; // Overdue
if (diffDays <= 1) return 'text-orange-600 font-medium'; // Due soon
return 'text-gray-600';
};
const getStockLevelColor = (current: number, needed: number) => {
const ratio = current / needed;
if (ratio <= 0.1) return 'text-red-600 font-medium'; // Critical
if (ratio <= 0.3) return 'text-orange-600 font-medium'; // Low
return 'text-gray-600';
};
if (requirements.length === 0) {
return (
<div className="text-center py-8 text-gray-500">
<p>No critical requirements at this time</p>
</div>
);
}
return (
<div className="space-y-3">
{requirements.map((requirement) => (
<div
key={requirement.id}
className="border border-red-200 rounded-lg p-4 bg-red-50 hover:bg-red-100 transition-colors"
>
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center space-x-2 mb-2">
<h4 className="font-medium text-gray-900">
{requirement.product_name}
</h4>
<span className={`px-2 py-1 rounded-full text-xs font-medium ${getStatusColor(requirement.status)}`}>
{requirement.status.replace('_', ' ').toUpperCase()}
</span>
<span className="px-2 py-1 rounded-full text-xs font-medium bg-red-100 text-red-700">
CRITICAL
</span>
</div>
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 text-sm">
<div>
<span className="text-gray-500">Required:</span>
<div className="font-medium">
{requirement.net_requirement} {requirement.unit_of_measure}
</div>
</div>
<div>
<span className="text-gray-500">Current Stock:</span>
<div className={getStockLevelColor(requirement.current_stock_level, requirement.net_requirement)}>
{requirement.current_stock_level} {requirement.unit_of_measure}
</div>
</div>
<div>
<span className="text-gray-500">Due Date:</span>
<div className={getDueDateColor(requirement.required_by_date)}>
{formatDate(requirement.required_by_date)}
</div>
</div>
<div>
<span className="text-gray-500">Est. Cost:</span>
<div className="font-medium">
{formatCurrency(requirement.estimated_total_cost)}
</div>
</div>
</div>
{requirement.supplier_name && (
<div className="mt-2 text-sm">
<span className="text-gray-500">Supplier:</span>
<span className="ml-1 font-medium">{requirement.supplier_name}</span>
{requirement.supplier_lead_time_days && (
<span className="ml-2 text-gray-500">
({requirement.supplier_lead_time_days} days lead time)
</span>
)}
</div>
)}
{requirement.special_requirements && (
<div className="mt-2 p-2 bg-yellow-50 rounded border border-yellow-200">
<span className="text-xs text-yellow-700 font-medium">Special Requirements:</span>
<p className="text-xs text-yellow-600 mt-1">
{requirement.special_requirements}
</p>
</div>
)}
</div>
<div className="flex flex-col space-y-2 ml-4">
{requirement.status === RequirementStatus.PENDING && (
<button
onClick={() => onUpdateStatus?.(requirement.id, RequirementStatus.APPROVED)}
className="px-3 py-1 bg-blue-600 text-white text-xs rounded hover:bg-blue-700 transition-colors"
>
Approve
</button>
)}
{requirement.status === RequirementStatus.APPROVED && (
<button
onClick={() => onUpdateStatus?.(requirement.id, RequirementStatus.ORDERED)}
className="px-3 py-1 bg-purple-600 text-white text-xs rounded hover:bg-purple-700 transition-colors"
>
Order Now
</button>
)}
<button
onClick={() => onViewDetails?.(requirement.id)}
className="px-3 py-1 bg-gray-200 text-gray-700 text-xs rounded hover:bg-gray-300 transition-colors"
>
Details
</button>
</div>
</div>
{/* Progress indicator for ordered items */}
{requirement.status === RequirementStatus.ORDERED && requirement.ordered_quantity > 0 && (
<div className="mt-3 pt-3 border-t border-red-200">
<div className="flex justify-between text-xs text-gray-600 mb-1">
<span>Order Progress</span>
<span>
{requirement.received_quantity} / {requirement.ordered_quantity} {requirement.unit_of_measure}
</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-2">
<div
className="bg-blue-600 h-2 rounded-full transition-all"
style={{
width: `${Math.min(100, (requirement.received_quantity / requirement.ordered_quantity) * 100)}%`
}}
/>
</div>
{requirement.expected_delivery_date && (
<div className="text-xs text-gray-500 mt-1">
Expected: {formatDate(requirement.expected_delivery_date)}
</div>
)}
</div>
)}
</div>
))}
</div>
);
};

View File

@@ -0,0 +1,202 @@
// ================================================================
// frontend/src/components/procurement/GeneratePlanModal.tsx
// ================================================================
/**
* Generate Plan Modal Component
* Modal for configuring and generating new procurement plans
*/
import React, { useState } from 'react';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import type { GeneratePlanRequest } from '@/api/types/procurement';
export interface GeneratePlanModalProps {
onGenerate: (request: GeneratePlanRequest) => void;
onClose: () => void;
isGenerating: boolean;
error?: Error | null;
}
export const GeneratePlanModal: React.FC<GeneratePlanModalProps> = ({
onGenerate,
onClose,
isGenerating,
error,
}) => {
const [formData, setFormData] = useState<GeneratePlanRequest>({
plan_date: new Date().toISOString().split('T')[0], // Today
force_regenerate: false,
planning_horizon_days: 14,
include_safety_stock: true,
safety_stock_percentage: 20,
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
onGenerate(formData);
};
const handleInputChange = (field: keyof GeneratePlanRequest, value: any) => {
setFormData(prev => ({
...prev,
[field]: value,
}));
};
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 w-full max-w-md mx-4">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-gray-900">
Generate Procurement Plan
</h2>
<button
onClick={onClose}
disabled={isGenerating}
className="text-gray-400 hover:text-gray-600"
>
</button>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
{/* Plan Date */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Plan Date
</label>
<Input
type="date"
value={formData.plan_date || ''}
onChange={(e) => handleInputChange('plan_date', e.target.value)}
disabled={isGenerating}
className="w-full"
/>
<p className="text-xs text-gray-500 mt-1">
Date for which to generate the procurement plan
</p>
</div>
{/* Planning Horizon */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Planning Horizon (days)
</label>
<Input
type="number"
min="1"
max="30"
value={formData.planning_horizon_days || 14}
onChange={(e) => handleInputChange('planning_horizon_days', parseInt(e.target.value))}
disabled={isGenerating}
className="w-full"
/>
<p className="text-xs text-gray-500 mt-1">
Number of days to plan ahead (1-30)
</p>
</div>
{/* Safety Stock */}
<div>
<div className="flex items-center mb-2">
<input
type="checkbox"
id="include_safety_stock"
checked={formData.include_safety_stock || false}
onChange={(e) => handleInputChange('include_safety_stock', e.target.checked)}
disabled={isGenerating}
className="h-4 w-4 text-blue-600 rounded border-gray-300"
/>
<label htmlFor="include_safety_stock" className="ml-2 text-sm font-medium text-gray-700">
Include Safety Stock
</label>
</div>
{formData.include_safety_stock && (
<div className="ml-6">
<label className="block text-sm font-medium text-gray-700 mb-1">
Safety Stock Percentage
</label>
<Input
type="number"
min="0"
max="100"
step="5"
value={formData.safety_stock_percentage || 20}
onChange={(e) => handleInputChange('safety_stock_percentage', parseFloat(e.target.value))}
disabled={isGenerating}
className="w-full"
/>
<p className="text-xs text-gray-500 mt-1">
Additional buffer stock as percentage of demand (0-100%)
</p>
</div>
)}
</div>
{/* Force Regenerate */}
<div className="flex items-center">
<input
type="checkbox"
id="force_regenerate"
checked={formData.force_regenerate || false}
onChange={(e) => handleInputChange('force_regenerate', e.target.checked)}
disabled={isGenerating}
className="h-4 w-4 text-blue-600 rounded border-gray-300"
/>
<label htmlFor="force_regenerate" className="ml-2 text-sm font-medium text-gray-700">
Force Regenerate
</label>
</div>
<p className="text-xs text-gray-500">
Regenerate plan even if one already exists for this date
</p>
{/* Error Display */}
{error && (
<div className="bg-red-50 border border-red-200 rounded p-3">
<p className="text-red-600 text-sm">
{error.message || 'Failed to generate plan'}
</p>
</div>
)}
{/* Action Buttons */}
<div className="flex items-center justify-end space-x-3 pt-4">
<Button
type="button"
variant="outline"
onClick={onClose}
disabled={isGenerating}
>
Cancel
</Button>
<Button
type="submit"
disabled={isGenerating}
>
{isGenerating ? 'Generating...' : 'Generate Plan'}
</Button>
</div>
</form>
{/* Generation Progress */}
{isGenerating && (
<div className="mt-4 p-3 bg-blue-50 rounded">
<div className="flex items-center">
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-blue-600 mr-2"></div>
<span className="text-sm text-blue-700">
Generating procurement plan...
</span>
</div>
<div className="mt-2 text-xs text-blue-600">
This may take a few moments while we analyze inventory and forecast demand.
</div>
</div>
)}
</div>
</div>
);
};

View File

@@ -0,0 +1,268 @@
// ================================================================
// frontend/src/components/procurement/ProcurementDashboard.tsx
// ================================================================
/**
* Procurement Dashboard Component
* Main dashboard for procurement planning functionality
*/
import React, { useState } from 'react';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { LoadingSpinner } from '@/components/ui/LoadingSpinner';
import {
useProcurementPlanDashboard,
useProcurementPlanActions
} from '@/api/hooks/useProcurement';
import type {
ProcurementPlan,
ProcurementRequirement,
GeneratePlanRequest
} from '@/api/types/procurement';
import { ProcurementPlanCard } from './ProcurementPlanCard';
import { ProcurementSummary } from './ProcurementSummary';
import { CriticalRequirements } from './CriticalRequirements';
import { GeneratePlanModal } from './GeneratePlanModal';
export interface ProcurementDashboardProps {
showFilters?: boolean;
refreshInterval?: number;
onPlanGenerated?: (plan: ProcurementPlan) => void;
}
export const ProcurementDashboard: React.FC<ProcurementDashboardProps> = ({
showFilters = true,
refreshInterval = 5 * 60 * 1000, // 5 minutes
onPlanGenerated,
}) => {
const [showGenerateModal, setShowGenerateModal] = useState(false);
const {
currentPlan,
dashboard,
criticalRequirements,
health,
isLoading,
error,
refetchAll
} = useProcurementPlanDashboard();
const {
generatePlan,
updateStatus,
triggerScheduler,
isGenerating,
generateError
} = useProcurementPlanActions();
const handleGeneratePlan = (request: GeneratePlanRequest) => {
generatePlan(request, {
onSuccess: (response) => {
if (response.success && response.plan) {
onPlanGenerated?.(response.plan);
setShowGenerateModal(false);
}
}
});
};
const handleStatusUpdate = (planId: string, status: string) => {
updateStatus({ planId, status });
};
const handleTriggerScheduler = () => {
triggerScheduler();
};
if (isLoading) {
return (
<div className="flex items-center justify-center py-8">
<LoadingSpinner size="lg" />
<span className="ml-2">Loading procurement dashboard...</span>
</div>
);
}
if (error) {
return (
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
<h3 className="text-red-800 font-medium">Error Loading Dashboard</h3>
<p className="text-red-600 mt-1">
{error.message || 'Unable to load procurement dashboard data'}
</p>
<Button
onClick={refetchAll}
className="mt-2"
variant="outline"
>
Retry
</Button>
</div>
);
}
const dashboardData = dashboard.data;
const currentPlanData = currentPlan.data;
const criticalReqs = criticalRequirements.data || [];
const serviceHealth = health.data;
return (
<div className="space-y-6">
{/* Header with Actions */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900">
Procurement Planning
</h1>
<p className="text-gray-600 mt-1">
Manage daily procurement plans and requirements
</p>
</div>
<div className="flex items-center space-x-3">
{serviceHealth && !serviceHealth.procurement_enabled && (
<div className="bg-yellow-100 border border-yellow-300 rounded px-3 py-1">
<span className="text-yellow-800 text-sm">
Service Disabled
</span>
</div>
)}
<Button
onClick={handleTriggerScheduler}
variant="outline"
disabled={isGenerating}
>
Run Scheduler
</Button>
<Button
onClick={() => setShowGenerateModal(true)}
disabled={isGenerating}
>
{isGenerating ? 'Generating...' : 'Generate Plan'}
</Button>
</div>
</div>
{/* Current Plan Section */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="lg:col-span-2">
<Card className="p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold">Today's Procurement Plan</h2>
<Button
onClick={refetchAll}
variant="ghost"
size="sm"
>
Refresh
</Button>
</div>
{currentPlanData ? (
<ProcurementPlanCard
plan={currentPlanData}
onUpdateStatus={handleStatusUpdate}
showActions={true}
/>
) : (
<div className="text-center py-8 text-gray-500">
<p>No procurement plan for today</p>
<Button
onClick={() => setShowGenerateModal(true)}
className="mt-2"
size="sm"
>
Generate Plan
</Button>
</div>
)}
</Card>
</div>
{/* Summary Statistics */}
<div>
<Card className="p-6">
<h2 className="text-lg font-semibold mb-4">Summary</h2>
{dashboardData?.summary ? (
<ProcurementSummary summary={dashboardData.summary} />
) : (
<div className="text-gray-500">No summary data available</div>
)}
</Card>
</div>
</div>
{/* Critical Requirements */}
{criticalReqs.length > 0 && (
<Card className="p-6">
<h2 className="text-lg font-semibold mb-4 text-red-600">
Critical Requirements ({criticalReqs.length})
</h2>
<CriticalRequirements requirements={criticalReqs} />
</Card>
)}
{/* Additional Dashboard Widgets */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{/* Upcoming Deliveries */}
{dashboardData?.upcoming_deliveries?.length > 0 && (
<Card className="p-6">
<h3 className="text-md font-semibold mb-3">Upcoming Deliveries</h3>
<div className="space-y-2">
{dashboardData.upcoming_deliveries.slice(0, 5).map((delivery, index) => (
<div key={index} className="flex justify-between text-sm">
<span>{delivery.product_name}</span>
<span className="text-gray-500">{delivery.expected_date}</span>
</div>
))}
</div>
</Card>
)}
{/* Low Stock Alerts */}
{dashboardData?.low_stock_alerts?.length > 0 && (
<Card className="p-6">
<h3 className="text-md font-semibold mb-3 text-orange-600">
Low Stock Alerts
</h3>
<div className="space-y-2">
{dashboardData.low_stock_alerts.slice(0, 5).map((alert, index) => (
<div key={index} className="flex justify-between text-sm">
<span>{alert.product_name}</span>
<span className="text-orange-600">{alert.current_stock}</span>
</div>
))}
</div>
</Card>
)}
{/* Performance Metrics */}
{dashboardData?.performance_metrics && (
<Card className="p-6">
<h3 className="text-md font-semibold mb-3">Performance</h3>
<div className="space-y-3">
{Object.entries(dashboardData.performance_metrics).map(([key, value]) => (
<div key={key} className="flex justify-between text-sm">
<span className="capitalize">{key.replace('_', ' ')}</span>
<span className="font-medium">{value as string}</span>
</div>
))}
</div>
</Card>
)}
</div>
{/* Generate Plan Modal */}
{showGenerateModal && (
<GeneratePlanModal
onGenerate={handleGeneratePlan}
onClose={() => setShowGenerateModal(false)}
isGenerating={isGenerating}
error={generateError}
/>
)}
</div>
);
};

View File

@@ -0,0 +1,235 @@
// ================================================================
// frontend/src/components/procurement/ProcurementPlanCard.tsx
// ================================================================
/**
* Procurement Plan Card Component
* Displays a procurement plan with key information and actions
*/
import React from 'react';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import type { ProcurementPlan } from '@/api/types/procurement';
import { PlanStatus, Priority } from '@/api/types/procurement';
export interface ProcurementPlanCardProps {
plan: ProcurementPlan;
onViewDetails?: (planId: string) => void;
onUpdateStatus?: (planId: string, status: string) => void;
showActions?: boolean;
}
export const ProcurementPlanCard: React.FC<ProcurementPlanCardProps> = ({
plan,
onViewDetails,
onUpdateStatus,
showActions = false,
}) => {
const getStatusColor = (status: string) => {
const colors = {
[PlanStatus.DRAFT]: 'bg-gray-100 text-gray-800',
[PlanStatus.PENDING_APPROVAL]: 'bg-yellow-100 text-yellow-800',
[PlanStatus.APPROVED]: 'bg-blue-100 text-blue-800',
[PlanStatus.IN_EXECUTION]: 'bg-green-100 text-green-800',
[PlanStatus.COMPLETED]: 'bg-green-100 text-green-800',
[PlanStatus.CANCELLED]: 'bg-red-100 text-red-800',
};
return colors[status as keyof typeof colors] || 'bg-gray-100 text-gray-800';
};
const getPriorityColor = (priority: string) => {
const colors = {
[Priority.CRITICAL]: 'text-red-600',
[Priority.HIGH]: 'text-orange-600',
[Priority.NORMAL]: 'text-blue-600',
[Priority.LOW]: 'text-gray-600',
};
return colors[priority as keyof typeof colors] || 'text-gray-600';
};
const getRiskColor = (risk: string) => {
const colors = {
'critical': 'text-red-600',
'high': 'text-orange-600',
'medium': 'text-yellow-600',
'low': 'text-green-600',
};
return colors[risk as keyof typeof colors] || 'text-gray-600';
};
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat('es-ES', {
style: 'currency',
currency: 'EUR'
}).format(amount);
};
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString('es-ES', {
day: 'numeric',
month: 'short',
year: 'numeric'
});
};
const nextStatusOptions = () => {
const options = {
[PlanStatus.DRAFT]: [PlanStatus.PENDING_APPROVAL, PlanStatus.CANCELLED],
[PlanStatus.PENDING_APPROVAL]: [PlanStatus.APPROVED, PlanStatus.CANCELLED],
[PlanStatus.APPROVED]: [PlanStatus.IN_EXECUTION, PlanStatus.CANCELLED],
[PlanStatus.IN_EXECUTION]: [PlanStatus.COMPLETED, PlanStatus.CANCELLED],
[PlanStatus.COMPLETED]: [],
[PlanStatus.CANCELLED]: [],
};
return options[plan.status as keyof typeof options] || [];
};
return (
<Card className="border border-gray-200">
<div className="p-6">
{/* Header */}
<div className="flex items-start justify-between mb-4">
<div>
<div className="flex items-center space-x-3">
<h3 className="text-lg font-semibold text-gray-900">
{plan.plan_number}
</h3>
<span className={`px-2 py-1 rounded-full text-xs font-medium ${getStatusColor(plan.status)}`}>
{plan.status.replace('_', ' ').toUpperCase()}
</span>
</div>
<p className="text-sm text-gray-600 mt-1">
Plan Date: {formatDate(plan.plan_date)} |
Period: {formatDate(plan.plan_period_start)} - {formatDate(plan.plan_period_end)}
</p>
</div>
<div className="text-right">
<div className={`text-sm font-medium ${getPriorityColor(plan.priority)}`}>
{plan.priority.toUpperCase()} Priority
</div>
<div className={`text-xs ${getRiskColor(plan.supply_risk_level)}`}>
{plan.supply_risk_level.toUpperCase()} Risk
</div>
</div>
</div>
{/* Key Metrics */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-4">
<div className="text-center">
<div className="text-2xl font-bold text-blue-600">
{plan.total_requirements}
</div>
<div className="text-xs text-gray-500">Requirements</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-green-600">
{formatCurrency(plan.total_estimated_cost)}
</div>
<div className="text-xs text-gray-500">Est. Cost</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-purple-600">
{plan.primary_suppliers_count}
</div>
<div className="text-xs text-gray-500">Suppliers</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-orange-600">
{plan.safety_stock_buffer}%
</div>
<div className="text-xs text-gray-500">Safety Buffer</div>
</div>
</div>
{/* Requirements Summary */}
{plan.requirements && plan.requirements.length > 0 && (
<div className="mb-4">
<h4 className="text-sm font-medium text-gray-700 mb-2">
Top Requirements ({plan.requirements.length} total)
</h4>
<div className="space-y-1">
{plan.requirements.slice(0, 3).map((req) => (
<div key={req.id} className="flex justify-between items-center text-sm">
<span className="truncate">{req.product_name}</span>
<div className="flex items-center space-x-2">
<span className="text-gray-500">
{req.net_requirement} {req.unit_of_measure}
</span>
<span className={`px-1 py-0.5 rounded text-xs ${
req.priority === Priority.CRITICAL ? 'bg-red-100 text-red-700' :
req.priority === Priority.HIGH ? 'bg-orange-100 text-orange-700' :
'bg-gray-100 text-gray-700'
}`}>
{req.priority}
</span>
</div>
</div>
))}
{plan.requirements.length > 3 && (
<div className="text-xs text-gray-500 text-center">
+{plan.requirements.length - 3} more requirements
</div>
)}
</div>
</div>
)}
{/* Performance Metrics */}
{(plan.fulfillment_rate || plan.on_time_delivery_rate) && (
<div className="flex items-center space-x-4 text-sm text-gray-600 mb-4">
{plan.fulfillment_rate && (
<span>Fulfillment: {plan.fulfillment_rate}%</span>
)}
{plan.on_time_delivery_rate && (
<span>On-time: {plan.on_time_delivery_rate}%</span>
)}
</div>
)}
{/* Actions */}
{showActions && (
<div className="flex items-center justify-between pt-4 border-t border-gray-100">
<div className="flex space-x-2">
{nextStatusOptions().map((status) => (
<Button
key={status}
size="sm"
variant="outline"
onClick={() => onUpdateStatus?.(plan.id, status)}
>
{status === PlanStatus.PENDING_APPROVAL && 'Submit for Approval'}
{status === PlanStatus.APPROVED && 'Approve'}
{status === PlanStatus.IN_EXECUTION && 'Start Execution'}
{status === PlanStatus.COMPLETED && 'Mark Complete'}
{status === PlanStatus.CANCELLED && 'Cancel'}
</Button>
))}
</div>
<Button
size="sm"
variant="ghost"
onClick={() => onViewDetails?.(plan.id)}
>
View Details
</Button>
</div>
)}
{/* Special Requirements */}
{plan.special_requirements && (
<div className="mt-4 p-3 bg-blue-50 rounded-lg">
<h5 className="text-sm font-medium text-blue-800 mb-1">
Special Requirements
</h5>
<p className="text-sm text-blue-700">{plan.special_requirements}</p>
</div>
)}
</div>
</Card>
);
};

View File

@@ -0,0 +1,171 @@
// ================================================================
// frontend/src/components/procurement/ProcurementSummary.tsx
// ================================================================
/**
* Procurement Summary Component
* Displays key metrics and summary information
*/
import React from 'react';
import type { ProcurementSummary } from '@/api/types/procurement';
export interface ProcurementSummaryProps {
summary: ProcurementSummary;
}
export const ProcurementSummary: React.FC<ProcurementSummaryProps> = ({
summary,
}) => {
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat('es-ES', {
style: 'currency',
currency: 'EUR'
}).format(amount);
};
const formatPercentage = (value: number | undefined) => {
if (value === undefined || value === null) return 'N/A';
return `${value.toFixed(1)}%`;
};
return (
<div className="space-y-6">
{/* Plan Metrics */}
<div>
<h4 className="text-sm font-medium text-gray-700 mb-3">Plan Overview</h4>
<div className="grid grid-cols-2 gap-4">
<div className="text-center">
<div className="text-xl font-bold text-blue-600">
{summary.total_plans}
</div>
<div className="text-xs text-gray-500">Total Plans</div>
</div>
<div className="text-center">
<div className="text-xl font-bold text-green-600">
{summary.active_plans}
</div>
<div className="text-xs text-gray-500">Active Plans</div>
</div>
</div>
</div>
{/* Requirements Metrics */}
<div>
<h4 className="text-sm font-medium text-gray-700 mb-3">Requirements</h4>
<div className="space-y-2">
<div className="flex justify-between">
<span className="text-sm text-gray-600">Total</span>
<span className="text-sm font-medium">{summary.total_requirements}</span>
</div>
<div className="flex justify-between">
<span className="text-sm text-gray-600">Pending</span>
<span className="text-sm font-medium text-yellow-600">
{summary.pending_requirements}
</span>
</div>
<div className="flex justify-between">
<span className="text-sm text-gray-600">Critical</span>
<span className="text-sm font-medium text-red-600">
{summary.critical_requirements}
</span>
</div>
</div>
</div>
{/* Cost Metrics */}
<div>
<h4 className="text-sm font-medium text-gray-700 mb-3">Financial</h4>
<div className="space-y-2">
<div className="flex justify-between">
<span className="text-sm text-gray-600">Estimated</span>
<span className="text-sm font-medium text-blue-600">
{formatCurrency(summary.total_estimated_cost)}
</span>
</div>
<div className="flex justify-between">
<span className="text-sm text-gray-600">Approved</span>
<span className="text-sm font-medium text-green-600">
{formatCurrency(summary.total_approved_cost)}
</span>
</div>
<div className="flex justify-between">
<span className="text-sm text-gray-600">Variance</span>
<span className={`text-sm font-medium ${
summary.cost_variance >= 0 ? 'text-green-600' : 'text-red-600'
}`}>
{summary.cost_variance >= 0 ? '+' : ''}
{formatCurrency(summary.cost_variance)}
</span>
</div>
</div>
</div>
{/* Performance Metrics */}
{(summary.average_fulfillment_rate || summary.average_on_time_delivery) && (
<div>
<h4 className="text-sm font-medium text-gray-700 mb-3">Performance</h4>
<div className="space-y-2">
{summary.average_fulfillment_rate && (
<div className="flex justify-between">
<span className="text-sm text-gray-600">Fulfillment Rate</span>
<span className="text-sm font-medium">
{formatPercentage(summary.average_fulfillment_rate)}
</span>
</div>
)}
{summary.average_on_time_delivery && (
<div className="flex justify-between">
<span className="text-sm text-gray-600">On-Time Delivery</span>
<span className="text-sm font-medium">
{formatPercentage(summary.average_on_time_delivery)}
</span>
</div>
)}
</div>
</div>
)}
{/* Top Suppliers */}
{summary.top_suppliers.length > 0 && (
<div>
<h4 className="text-sm font-medium text-gray-700 mb-3">Top Suppliers</h4>
<div className="space-y-1">
{summary.top_suppliers.slice(0, 3).map((supplier, index) => (
<div key={index} className="flex justify-between text-sm">
<span className="truncate">{supplier.name}</span>
<span className="text-gray-500">
{supplier.count || 0} orders
</span>
</div>
))}
</div>
</div>
)}
{/* Critical Items */}
{summary.critical_items.length > 0 && (
<div>
<h4 className="text-sm font-medium text-gray-700 mb-3 text-red-600">
Critical Items
</h4>
<div className="space-y-1">
{summary.critical_items.slice(0, 3).map((item, index) => (
<div key={index} className="flex justify-between text-sm">
<span className="truncate">{item.name}</span>
<span className="text-red-500">
{item.stock || 0} left
</span>
</div>
))}
</div>
</div>
)}
</div>
);
};

View File

@@ -0,0 +1,18 @@
// ================================================================
// frontend/src/components/procurement/index.ts
// ================================================================
/**
* Procurement Components Export
* Main export point for procurement planning components
*/
export { ProcurementDashboard } from './ProcurementDashboard';
export { ProcurementPlanCard } from './ProcurementPlanCard';
export { ProcurementSummary } from './ProcurementSummary';
export { CriticalRequirements } from './CriticalRequirements';
export { GeneratePlanModal } from './GeneratePlanModal';
export type {
ProcurementDashboardProps,
ProcurementPlanCardProps
} from './ProcurementDashboard';

View File

@@ -0,0 +1 @@
export { default as ProcurementPage } from './ProcurementPage';