Add frontend procurement implementation

This commit is contained in:
Urtzi Alfaro
2025-09-09 12:02:41 +02:00
parent bc3d0ff90c
commit a290663ec5
10 changed files with 1051 additions and 371 deletions

View File

@@ -18,6 +18,19 @@ import {
GetCustomersParams, GetCustomersParams,
UpdateOrderStatusParams, UpdateOrderStatusParams,
GetDemandRequirementsParams, GetDemandRequirementsParams,
// Procurement types
ProcurementPlanResponse,
ProcurementPlanCreate,
ProcurementPlanUpdate,
ProcurementRequirementResponse,
ProcurementRequirementUpdate,
ProcurementDashboardData,
GeneratePlanRequest,
GeneratePlanResponse,
PaginatedProcurementPlans,
GetProcurementPlansParams,
GetPlanRequirementsParams,
UpdatePlanStatusParams,
} from '../types/orders'; } from '../types/orders';
import { ApiError } from '../client/apiClient'; import { ApiError } from '../client/apiClient';
@@ -42,6 +55,17 @@ export const ordersKeys = {
// Status // Status
status: (tenantId: string) => [...ordersKeys.all, 'status', tenantId] as const, 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; } as const;
// ===== Order Queries ===== // ===== Order Queries =====
@@ -330,3 +354,194 @@ 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,
});
};

View File

@@ -269,6 +269,30 @@ export type {
GetCustomersParams, GetCustomersParams,
UpdateOrderStatusParams, UpdateOrderStatusParams,
GetDemandRequirementsParams, GetDemandRequirementsParams,
// Procurement types
ProcurementPlanType,
ProcurementStrategy,
RiskLevel,
RequirementStatus,
PlanStatus,
DeliveryStatus,
ProcurementRequirementBase,
ProcurementRequirementCreate,
ProcurementRequirementUpdate,
ProcurementRequirementResponse,
ProcurementPlanBase,
ProcurementPlanCreate,
ProcurementPlanUpdate,
ProcurementPlanResponse,
ProcurementSummary,
ProcurementDashboardData,
GeneratePlanRequest,
GeneratePlanResponse,
PaginatedProcurementPlans,
ForecastRequest,
GetProcurementPlansParams,
GetPlanRequirementsParams,
UpdatePlanStatusParams,
} from './types/orders'; } from './types/orders';
// Hooks - Auth // Hooks - Auth
@@ -512,6 +536,18 @@ export {
useCreateCustomer, useCreateCustomer,
useUpdateCustomer, useUpdateCustomer,
useInvalidateOrders, useInvalidateOrders,
// Procurement hooks
useProcurementPlans,
useProcurementPlan,
useProcurementPlanByDate,
useCurrentProcurementPlan,
useProcurementDashboard,
usePlanRequirements,
useCriticalRequirements,
useProcurementHealth,
useGenerateProcurementPlan,
useUpdateProcurementPlanStatus,
useTriggerDailyScheduler,
ordersKeys, ordersKeys,
} from './hooks/orders'; } from './hooks/orders';

View File

@@ -21,6 +21,19 @@ import {
GetCustomersParams, GetCustomersParams,
UpdateOrderStatusParams, UpdateOrderStatusParams,
GetDemandRequirementsParams, GetDemandRequirementsParams,
// Procurement types
ProcurementPlanResponse,
ProcurementPlanCreate,
ProcurementPlanUpdate,
ProcurementRequirementResponse,
ProcurementRequirementUpdate,
ProcurementDashboardData,
GeneratePlanRequest,
GeneratePlanResponse,
PaginatedProcurementPlans,
GetProcurementPlansParams,
GetPlanRequirementsParams,
UpdatePlanStatusParams,
} from '../types/orders'; } from '../types/orders';
export class OrdersService { export class OrdersService {
@@ -168,6 +181,127 @@ export class OrdersService {
static async getServiceStatus(tenantId: string): Promise<ServiceStatus> { static async getServiceStatus(tenantId: string): Promise<ServiceStatus> {
return apiClient.get<ServiceStatus>(`/tenants/${tenantId}/orders/status`); return apiClient.get<ServiceStatus>(`/tenants/${tenantId}/orders/status`);
} }
// ===== Procurement Planning Endpoints =====
/**
* Get current procurement plan for today
* GET /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 specific date
* GET /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}`);
}
/**
* Get procurement plan by ID
* GET /tenants/{tenant_id}/procurement/plans/id/{plan_id}
*/
static async getProcurementPlanById(tenantId: string, planId: string): Promise<ProcurementPlanResponse | null> {
return apiClient.get<ProcurementPlanResponse | null>(`/tenants/${tenantId}/procurement/plans/id/${planId}`);
}
/**
* List procurement plans with filtering
* GET /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()}`
);
}
/**
* Generate a new procurement plan
* POST /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);
}
/**
* Update procurement plan status
* PUT /tenants/{tenant_id}/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}/procurement/plans/${plan_id}/status?${queryParams.toString()}`,
{}
);
}
/**
* Get procurement dashboard data
* GET /tenants/{tenant_id}/procurement/dashboard
*/
static async getProcurementDashboard(tenantId: string): Promise<ProcurementDashboardData | null> {
return apiClient.get<ProcurementDashboardData | null>(`/tenants/${tenantId}/procurement/dashboard`);
}
/**
* Get requirements for a specific plan
* GET /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 /tenants/{tenant_id}/procurement/requirements/critical
*/
static async getCriticalRequirements(tenantId: string): Promise<ProcurementRequirementResponse[]> {
return apiClient.get<ProcurementRequirementResponse[]>(`/tenants/${tenantId}/procurement/requirements/critical`);
}
/**
* Trigger daily scheduler manually
* POST /tenants/{tenant_id}/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}/procurement/scheduler/trigger`,
{}
);
}
/**
* Get procurement service health
* GET /tenants/{tenant_id}/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}/procurement/health`);
}
} }
export default OrdersService; export default OrdersService;

View File

@@ -281,3 +281,242 @@ export interface GetDemandRequirementsParams {
tenant_id: string; tenant_id: string;
target_date: string; target_date: string;
} }
// ===== Procurement Types =====
export type ProcurementPlanType = 'regular' | 'emergency' | 'seasonal';
export type ProcurementStrategy = 'just_in_time' | 'bulk' | 'mixed';
export type RiskLevel = 'low' | 'medium' | 'high' | 'critical';
export type RequirementStatus = 'pending' | 'approved' | 'ordered' | 'partially_received' | 'received' | 'cancelled';
export type PlanStatus = 'draft' | 'pending_approval' | 'approved' | 'in_execution' | 'completed' | 'cancelled';
export type DeliveryStatus = 'pending' | 'in_transit' | 'delivered' | 'delayed' | 'cancelled';
// 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;
}
// 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 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;
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[];
}
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;
}

View File

@@ -1,239 +1,209 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { Plus, Search, Download, ShoppingCart, Truck, DollarSign, Calendar, Clock, CheckCircle, AlertCircle, Package, Eye, Edit } from 'lucide-react'; import { Plus, Search, Download, ShoppingCart, Truck, DollarSign, Calendar, Clock, CheckCircle, AlertCircle, Package, Eye, Loader } from 'lucide-react';
import { Button, Input, Card, Badge, StatsGrid, StatusCard, getStatusColor, StatusModal } from '../../../../components/ui'; import { Button, Input, Card, StatsGrid, StatusCard, getStatusColor, StatusModal } from '../../../../components/ui';
import { formatters } from '../../../../components/ui/Stats/StatsPresets'; import { formatters } from '../../../../components/ui/Stats/StatsPresets';
import { PageHeader } from '../../../../components/layout'; import { PageHeader } from '../../../../components/layout';
import {
useProcurementDashboard,
useProcurementPlans,
useCurrentProcurementPlan,
useCriticalRequirements,
useGenerateProcurementPlan,
useUpdateProcurementPlanStatus,
useTriggerDailyScheduler
} from '../../../../api';
import { useTenantStore } from '../../../../stores/tenant.store';
const ProcurementPage: React.FC = () => { const ProcurementPage: React.FC = () => {
const [activeTab, setActiveTab] = useState('orders'); const [activeTab, setActiveTab] = useState('plans');
const [searchTerm, setSearchTerm] = useState(''); const [searchTerm, setSearchTerm] = useState('');
const [showForm, setShowForm] = useState(false); const [showForm, setShowForm] = useState(false);
const [modalMode, setModalMode] = useState<'view' | 'edit'>('view'); const [modalMode, setModalMode] = useState<'view' | 'edit'>('view');
const [selectedOrder, setSelectedOrder] = useState<typeof mockPurchaseOrders[0] | null>(null); const [selectedPlan, setSelectedPlan] = useState<any>(null);
const mockPurchaseOrders = [ const { currentTenant } = useTenantStore();
{ const tenantId = currentTenant?.id || '';
id: 'PO-2024-001',
supplier: 'Molinos del Sur',
status: 'pending',
orderDate: '2024-01-25',
deliveryDate: '2024-01-28',
totalAmount: 1250.00,
items: [
{ name: 'Harina de Trigo', quantity: 50, unit: 'kg', price: 1.20, total: 60.00 },
{ name: 'Harina Integral', quantity: 100, unit: 'kg', price: 1.30, total: 130.00 },
],
paymentStatus: 'pending',
notes: 'Entrega en horario de mañana',
},
{
id: 'PO-2024-002',
supplier: 'Levaduras SA',
status: 'delivered',
orderDate: '2024-01-20',
deliveryDate: '2024-01-23',
totalAmount: 425.50,
items: [
{ name: 'Levadura Fresca', quantity: 5, unit: 'kg', price: 8.50, total: 42.50 },
{ name: 'Mejorante', quantity: 10, unit: 'kg', price: 12.30, total: 123.00 },
],
paymentStatus: 'paid',
notes: '',
},
{
id: 'PO-2024-003',
supplier: 'Lácteos Frescos',
status: 'in_transit',
orderDate: '2024-01-24',
deliveryDate: '2024-01-26',
totalAmount: 320.75,
items: [
{ name: 'Mantequilla', quantity: 20, unit: 'kg', price: 5.80, total: 116.00 },
{ name: 'Nata', quantity: 15, unit: 'L', price: 3.25, total: 48.75 },
],
paymentStatus: 'pending',
notes: 'Producto refrigerado',
},
];
const mockSuppliers = [ // Real API data hooks
{ const { data: dashboardData, isLoading: isDashboardLoading } = useProcurementDashboard(tenantId);
id: '1', const { data: procurementPlans, isLoading: isPlansLoading } = useProcurementPlans({
name: 'Molinos del Sur', tenant_id: tenantId,
contact: 'Juan Pérez', limit: 50,
email: 'juan@molinosdelsur.com', offset: 0
phone: '+34 91 234 5678', });
category: 'Harinas', const { data: currentPlan, isLoading: isCurrentPlanLoading } = useCurrentProcurementPlan(tenantId);
rating: 4.8, const { data: criticalRequirements, isLoading: isCriticalLoading } = useCriticalRequirements(tenantId);
totalOrders: 24,
totalSpent: 15600.00,
paymentTerms: '30 días',
leadTime: '2-3 días',
location: 'Sevilla',
status: 'active',
},
{
id: '2',
name: 'Levaduras SA',
contact: 'María González',
email: 'maria@levaduras.com',
phone: '+34 93 456 7890',
category: 'Levaduras',
rating: 4.6,
totalOrders: 18,
totalSpent: 8450.00,
paymentTerms: '15 días',
leadTime: '1-2 días',
location: 'Barcelona',
status: 'active',
},
{
id: '3',
name: 'Lácteos Frescos',
contact: 'Carlos Ruiz',
email: 'carlos@lacteosfrescos.com',
phone: '+34 96 789 0123',
category: 'Lácteos',
rating: 4.4,
totalOrders: 32,
totalSpent: 12300.00,
paymentTerms: '20 días',
leadTime: '1 día',
location: 'Valencia',
status: 'active',
},
];
const getPurchaseStatusConfig = (status: string, paymentStatus: string) => { const generatePlanMutation = useGenerateProcurementPlan();
const updatePlanStatusMutation = useUpdateProcurementPlanStatus();
const triggerSchedulerMutation = useTriggerDailyScheduler();
if (!tenantId) {
return (
<div className="flex justify-center items-center h-64">
<div className="text-center">
<h3 className="text-lg font-medium text-[var(--text-primary)] mb-2">
No hay tenant seleccionado
</h3>
<p className="text-[var(--text-secondary)]">
Selecciona un tenant para ver los datos de procurement
</p>
</div>
</div>
);
}
const getPlanStatusConfig = (status: string) => {
const statusConfig = { const statusConfig = {
pending: { text: 'Pendiente', icon: Clock }, draft: { text: 'Borrador', icon: Clock },
pending_approval: { text: 'Pendiente Aprobación', icon: Clock },
approved: { text: 'Aprobado', icon: CheckCircle }, approved: { text: 'Aprobado', icon: CheckCircle },
in_transit: { text: 'En Tránsito', icon: Truck }, in_execution: { text: 'En Ejecución', icon: Truck },
delivered: { text: 'Entregado', icon: CheckCircle }, completed: { text: 'Completado', icon: CheckCircle },
cancelled: { text: 'Cancelado', icon: AlertCircle }, cancelled: { text: 'Cancelado', icon: AlertCircle },
}; };
const config = statusConfig[status as keyof typeof statusConfig]; const config = statusConfig[status as keyof typeof statusConfig];
const Icon = config?.icon; const Icon = config?.icon;
const isPaymentPending = paymentStatus === 'pending';
const isOverdue = paymentStatus === 'overdue';
return { return {
color: getStatusColor(status === 'in_transit' ? 'inTransit' : status), color: getStatusColor(status === 'in_execution' ? 'inTransit' : status === 'pending_approval' ? 'pending' : status),
text: config?.text || status, text: config?.text || status,
icon: Icon, icon: Icon,
isCritical: isOverdue, isCritical: status === 'cancelled',
isHighlight: isPaymentPending isHighlight: status === 'pending_approval'
}; };
}; };
const filteredOrders = mockPurchaseOrders.filter(order => { const filteredPlans = procurementPlans?.plans?.filter(plan => {
const matchesSearch = order.supplier.toLowerCase().includes(searchTerm.toLowerCase()) || const matchesSearch = plan.plan_number.toLowerCase().includes(searchTerm.toLowerCase()) ||
order.id.toLowerCase().includes(searchTerm.toLowerCase()) || plan.status.toLowerCase().includes(searchTerm.toLowerCase()) ||
order.notes.toLowerCase().includes(searchTerm.toLowerCase()); (plan.special_requirements && plan.special_requirements.toLowerCase().includes(searchTerm.toLowerCase()));
return matchesSearch; return matchesSearch;
}); }) || [];
const mockPurchaseStats = { const stats = {
totalOrders: mockPurchaseOrders.length, totalPlans: dashboardData?.summary?.total_plans || 0,
pendingOrders: mockPurchaseOrders.filter(o => o.status === 'pending').length, activePlans: dashboardData?.summary?.active_plans || 0,
inTransit: mockPurchaseOrders.filter(o => o.status === 'in_transit').length, pendingRequirements: dashboardData?.summary?.pending_requirements || 0,
delivered: mockPurchaseOrders.filter(o => o.status === 'delivered').length, criticalRequirements: dashboardData?.summary?.critical_requirements || 0,
totalSpent: mockPurchaseOrders.reduce((sum, order) => sum + order.totalAmount, 0), totalEstimatedCost: dashboardData?.summary?.total_estimated_cost || 0,
activeSuppliers: mockSuppliers.filter(s => s.status === 'active').length, totalApprovedCost: dashboardData?.summary?.total_approved_cost || 0,
}; };
const purchaseOrderStats = [ const procurementStats = [
{ {
title: 'Total Órdenes', title: 'Planes Totales',
value: mockPurchaseStats.totalOrders, value: stats.totalPlans,
variant: 'default' as const, variant: 'default' as const,
icon: ShoppingCart, icon: Package,
}, },
{ {
title: 'Pendientes', title: 'Planes Activos',
value: mockPurchaseStats.pendingOrders, value: stats.activePlans,
variant: 'warning' as const,
icon: Clock,
},
{
title: 'En Tránsito',
value: mockPurchaseStats.inTransit,
variant: 'info' as const,
icon: Truck,
},
{
title: 'Entregadas',
value: mockPurchaseStats.delivered,
variant: 'success' as const, variant: 'success' as const,
icon: CheckCircle, icon: CheckCircle,
}, },
{ {
title: 'Gasto Total', title: 'Requerimientos Pendientes',
value: formatters.currency(mockPurchaseStats.totalSpent), value: stats.pendingRequirements,
variant: 'success' as const, variant: 'warning' as const,
icon: Clock,
},
{
title: 'Críticos',
value: stats.criticalRequirements,
variant: 'warning' as const,
icon: AlertCircle,
},
{
title: 'Costo Estimado',
value: formatters.currency(stats.totalEstimatedCost),
variant: 'info' as const,
icon: DollarSign, icon: DollarSign,
}, },
{ {
title: 'Proveedores', title: 'Costo Aprobado',
value: mockPurchaseStats.activeSuppliers, value: formatters.currency(stats.totalApprovedCost),
variant: 'info' as const, variant: 'success' as const,
icon: Package, icon: DollarSign,
}, },
]; ];
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<PageHeader <PageHeader
title="Gestión de Compras" title="Planificación de Compras"
description="Administra órdenes de compra, proveedores y seguimiento de entregas" description="Administra planes de compras, requerimientos y análisis de procurement"
actions={[ actions={[
{ {
id: "export", id: "export",
label: "Exportar", label: "Exportar",
variant: "outline" as const, variant: "outline" as const,
icon: Download, icon: Download,
onClick: () => console.log('Export purchase orders') onClick: () => console.log('Export procurement data')
}, },
{ {
id: "new", id: "generate",
label: "Nueva Orden de Compra", label: "Generar Plan",
variant: "primary" as const, variant: "primary" as const,
icon: Plus, icon: Plus,
onClick: () => console.log('New purchase order') onClick: () => generatePlanMutation.mutate({
tenantId,
request: {
force_regenerate: false,
planning_horizon_days: 14,
include_safety_stock: true,
safety_stock_percentage: 20
}
})
},
{
id: "trigger",
label: "Ejecutar Programador",
variant: "outline" as const,
icon: Calendar,
onClick: () => triggerSchedulerMutation.mutate(tenantId)
} }
]} ]}
/> />
{/* Stats Grid */} {/* Stats Grid */}
<StatsGrid {isDashboardLoading ? (
stats={purchaseOrderStats} <div className="flex justify-center items-center h-32">
columns={3} <Loader className="w-8 h-8 animate-spin text-[var(--color-primary)]" />
/> </div>
) : (
<StatsGrid
stats={procurementStats}
columns={3}
/>
)}
{/* Tabs Navigation */} {/* Tabs Navigation */}
<div className="border-b border-[var(--border-primary)]"> <div className="border-b border-[var(--border-primary)]">
<nav className="-mb-px flex space-x-8"> <nav className="-mb-px flex space-x-8">
<button <button
onClick={() => setActiveTab('orders')} onClick={() => setActiveTab('plans')}
className={`py-2 px-1 border-b-2 font-medium text-sm ${ className={`py-2 px-1 border-b-2 font-medium text-sm ${
activeTab === 'orders' activeTab === 'plans'
? 'border-orange-500 text-[var(--color-primary)]' ? 'border-orange-500 text-[var(--color-primary)]'
: 'border-transparent text-[var(--text-tertiary)] hover:text-[var(--text-secondary)] hover:border-[var(--border-secondary)]' : 'border-transparent text-[var(--text-tertiary)] hover:text-[var(--text-secondary)] hover:border-[var(--border-secondary)]'
}`} }`}
> >
Órdenes de Compra Planes de Compra
</button> </button>
<button <button
onClick={() => setActiveTab('suppliers')} onClick={() => setActiveTab('requirements')}
className={`py-2 px-1 border-b-2 font-medium text-sm ${ className={`py-2 px-1 border-b-2 font-medium text-sm ${
activeTab === 'suppliers' activeTab === 'requirements'
? 'border-orange-500 text-[var(--color-primary)]' ? 'border-orange-500 text-[var(--color-primary)]'
: 'border-transparent text-[var(--text-tertiary)] hover:text-[var(--text-secondary)] hover:border-[var(--border-secondary)]' : 'border-transparent text-[var(--text-tertiary)] hover:text-[var(--text-secondary)] hover:border-[var(--border-secondary)]'
}`} }`}
> >
Proveedores Requerimientos Críticos
</button> </button>
<button <button
onClick={() => setActiveTab('analytics')} onClick={() => setActiveTab('analytics')}
@@ -248,12 +218,12 @@ const ProcurementPage: React.FC = () => {
</nav> </nav>
</div> </div>
{activeTab === 'orders' && ( {activeTab === 'plans' && (
<Card className="p-4"> <Card className="p-4">
<div className="flex flex-col sm:flex-row gap-4"> <div className="flex flex-col sm:flex-row gap-4">
<div className="flex-1"> <div className="flex-1">
<Input <Input
placeholder="Buscar órdenes por proveedor, ID o notas..." placeholder="Buscar planes por número, estado o notas..."
value={searchTerm} value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)} onChange={(e) => setSearchTerm(e.target.value)}
className="w-full" className="w-full"
@@ -267,148 +237,151 @@ const ProcurementPage: React.FC = () => {
</Card> </Card>
)} )}
{/* Purchase Orders Grid */} {/* Procurement Plans Grid */}
{activeTab === 'orders' && ( {activeTab === 'plans' && (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3"> <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{filteredOrders.map((order) => { {isPlansLoading ? (
const statusConfig = getPurchaseStatusConfig(order.status, order.paymentStatus); <div className="col-span-full flex justify-center items-center h-32">
const paymentNote = order.paymentStatus === 'pending' ? 'Pago pendiente' : order.paymentStatus === 'overdue' ? 'Pago vencido' : ''; <Loader className="w-8 h-8 animate-spin text-[var(--color-primary)]" />
</div>
) : (
filteredPlans.map((plan) => {
const statusConfig = getPlanStatusConfig(plan.status);
return ( return (
<StatusCard <StatusCard
key={order.id} key={plan.id}
id={order.id} id={plan.plan_number}
statusIndicator={statusConfig} statusIndicator={statusConfig}
title={order.supplier} title={`Plan ${plan.plan_number}`}
subtitle={order.id} subtitle={new Date(plan.plan_date).toLocaleDateString('es-ES')}
primaryValue={formatters.currency(order.totalAmount)} primaryValue={formatters.currency(plan.total_estimated_cost)}
primaryValueLabel={`${order.items?.length} artículos`} primaryValueLabel={`${plan.total_requirements} requerimientos`}
secondaryInfo={{ secondaryInfo={{
label: 'Entrega', label: 'Período',
value: `${new Date(order.deliveryDate).toLocaleDateString('es-ES')} (pedido: ${new Date(order.orderDate).toLocaleDateString('es-ES')})` value: `${new Date(plan.plan_period_start).toLocaleDateString('es-ES')} - ${new Date(plan.plan_period_end).toLocaleDateString('es-ES')}`
}} }}
metadata={[ metadata={[
...(order.notes ? [`"${order.notes}"`] : []), `${plan.planning_horizon_days} días de horizonte`,
...(paymentNote ? [paymentNote] : []) `Estrategia: ${plan.procurement_strategy}`,
]} ...(plan.special_requirements ? [`"${plan.special_requirements}"`] : [])
actions={[ ]}
{ actions={[
label: 'Ver', {
icon: Eye, label: 'Ver',
variant: 'outline', icon: Eye,
onClick: () => { variant: 'outline',
setSelectedOrder(order); onClick: () => {
setModalMode('view'); setSelectedPlan(plan);
setShowForm(true); setModalMode('view');
} setShowForm(true);
}, }
{ },
label: 'Editar', ...(plan.status === 'pending_approval' ? [{
icon: Edit, label: 'Aprobar',
variant: 'outline', icon: CheckCircle,
onClick: () => { variant: 'outline' as const,
setSelectedOrder(order); onClick: () => {
setModalMode('edit'); updatePlanStatusMutation.mutate({
setShowForm(true); tenant_id: tenantId,
} plan_id: plan.id,
} status: 'approved'
]} });
/> }
); }] : [])
})} ]}
/>
);
})
)}
</div> </div>
)} )}
{/* Empty State for Purchase Orders */} {/* Empty State for Procurement Plans */}
{activeTab === 'orders' && filteredOrders.length === 0 && ( {activeTab === 'plans' && !isPlansLoading && filteredPlans.length === 0 && (
<div className="text-center py-12"> <div className="text-center py-12">
<ShoppingCart className="mx-auto h-12 w-12 text-[var(--text-tertiary)] mb-4" /> <Package 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"> <h3 className="text-lg font-medium text-[var(--text-primary)] mb-2">
No se encontraron órdenes de compra No se encontraron planes de compra
</h3> </h3>
<p className="text-[var(--text-secondary)] mb-4"> <p className="text-[var(--text-secondary)] mb-4">
Intenta ajustar la búsqueda o crear una nueva orden de compra Intenta ajustar la búsqueda o generar un nuevo plan de compra
</p> </p>
<Button onClick={() => console.log('New purchase order')}> <Button
<Plus className="w-4 h-4 mr-2" /> onClick={() => generatePlanMutation.mutate({
Nueva Orden de Compra tenantId,
request: {
force_regenerate: false,
planning_horizon_days: 14,
include_safety_stock: true,
safety_stock_percentage: 20
}
})}
disabled={generatePlanMutation.isPending}
>
{generatePlanMutation.isPending ? (
<Loader className="w-4 h-4 mr-2 animate-spin" />
) : (
<Plus className="w-4 h-4 mr-2" />
)}
Generar Plan de Compra
</Button> </Button>
</div> </div>
)} )}
{activeTab === 'suppliers' && ( {activeTab === 'requirements' && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"> <div className="space-y-4">
{mockSuppliers.map((supplier) => ( {isCriticalLoading ? (
<Card key={supplier.id} className="p-6"> <div className="flex justify-center items-center h-32">
<div className="flex items-start justify-between mb-4"> <Loader className="w-8 h-8 animate-spin text-[var(--color-primary)]" />
<div> </div>
<h3 className="text-lg font-semibold text-[var(--text-primary)]">{supplier.name}</h3> ) : criticalRequirements && criticalRequirements.length > 0 ? (
<p className="text-sm text-[var(--text-secondary)]">{supplier.category}</p> <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
</div> {criticalRequirements.map((requirement) => (
<Badge variant="green">Activo</Badge> <StatusCard
</div> key={requirement.id}
id={requirement.requirement_number}
<div className="space-y-2 mb-4"> statusIndicator={{
<div className="flex justify-between text-sm"> color: getStatusColor('danger'),
<span className="text-[var(--text-secondary)]">Contacto:</span> text: 'Crítico',
<span className="font-medium">{supplier.contact}</span> icon: AlertCircle,
</div> isCritical: true
<div className="flex justify-between text-sm"> }}
<span className="text-[var(--text-secondary)]">Email:</span> title={requirement.product_name}
<span className="font-medium">{supplier.email}</span> subtitle={requirement.requirement_number}
</div> primaryValue={`${requirement.required_quantity} ${requirement.unit_of_measure}`}
<div className="flex justify-between text-sm"> primaryValueLabel="Cantidad requerida"
<span className="text-[var(--text-secondary)]">Teléfono:</span> secondaryInfo={{
<span className="font-medium">{supplier.phone}</span> label: 'Fecha límite',
</div> value: new Date(requirement.required_by_date).toLocaleDateString('es-ES')
<div className="flex justify-between text-sm"> }}
<span className="text-[var(--text-secondary)]">Ubicación:</span> metadata={[
<span className="font-medium">{supplier.location}</span> `Stock actual: ${requirement.current_stock_level} ${requirement.unit_of_measure}`,
</div> `Proveedor: ${requirement.supplier_name || 'No asignado'}`,
</div> `Costo estimado: ${formatters.currency(requirement.estimated_total_cost || 0)}`
]}
<div className="border-t pt-4"> actions={[
<div className="grid grid-cols-2 gap-4 mb-4"> {
<div> label: 'Ver Detalles',
<p className="text-xs text-[var(--text-secondary)]">Valoración</p> icon: Eye,
<p className="text-sm font-medium flex items-center"> variant: 'outline',
<span className="text-yellow-500"></span> onClick: () => console.log('View requirement details')
<span className="ml-1">{supplier.rating}</span> }
</p> ]}
</div> />
<div> ))}
<p className="text-xs text-[var(--text-secondary)]">Pedidos</p> </div>
<p className="text-sm font-medium">{supplier.totalOrders}</p> ) : (
</div> <div className="text-center py-12">
</div> <CheckCircle className="mx-auto h-12 w-12 text-green-500 mb-4" />
<h3 className="text-lg font-medium text-[var(--text-primary)] mb-2">
<div className="grid grid-cols-2 gap-4 mb-4"> No hay requerimientos críticos
<div> </h3>
<p className="text-xs text-[var(--text-secondary)]">Total Gastado</p> <p className="text-[var(--text-secondary)]">
<p className="text-sm font-medium">{supplier.totalSpent.toLocaleString()}</p> Todos los requerimientos están bajo control
</div> </p>
<div> </div>
<p className="text-xs text-[var(--text-secondary)]">Tiempo Entrega</p> )}
<p className="text-sm font-medium">{supplier.leadTime}</p>
</div>
</div>
<div className="mb-4">
<p className="text-xs text-[var(--text-secondary)]">Condiciones de Pago</p>
<p className="text-sm font-medium">{supplier.paymentTerms}</p>
</div>
<div className="flex space-x-2">
<Button variant="outline" size="sm" className="flex-1">
Ver Detalles
</Button>
<Button size="sm" className="flex-1">
Nuevo Pedido
</Button>
</div>
</div>
</Card>
))}
</div> </div>
)} )}
@@ -416,100 +389,139 @@ const ProcurementPage: React.FC = () => {
<div className="space-y-6"> <div className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6"> <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<Card className="p-6"> <Card className="p-6">
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-4">Gastos por Mes</h3> <h3 className="text-lg font-semibold text-[var(--text-primary)] mb-4">Costos de Procurement</h3>
<div className="h-64 flex items-center justify-center bg-[var(--bg-secondary)] rounded-lg"> <div className="space-y-4">
<p className="text-[var(--text-tertiary)]">Gráfico de gastos mensuales</p> <div className="flex justify-between items-center">
<span className="text-sm text-[var(--text-secondary)]">Costo Estimado Total</span>
<span className="text-lg font-semibold text-[var(--text-primary)]">
{formatters.currency(stats.totalEstimatedCost)}
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-sm text-[var(--text-secondary)]">Costo Aprobado</span>
<span className="text-lg font-semibold text-green-600">
{formatters.currency(stats.totalApprovedCost)}
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-sm text-[var(--text-secondary)]">Varianza</span>
<span className="text-lg font-semibold text-[var(--text-primary)]">
{formatters.currency(stats.totalEstimatedCost - stats.totalApprovedCost)}
</span>
</div>
</div> </div>
</Card> </Card>
<Card className="p-6"> <Card className="p-6">
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-4">Top Proveedores</h3> <h3 className="text-lg font-semibold text-[var(--text-primary)] mb-4">Alertas Críticas</h3>
<div className="space-y-3"> <div className="space-y-3">
{mockSuppliers {dashboardData?.low_stock_alerts?.slice(0, 5).map((alert: any, index: number) => (
.sort((a, b) => b.totalSpent - a.totalSpent) <div key={index} className="flex items-center justify-between p-3 bg-red-50 rounded-lg">
.slice(0, 5) <div className="flex items-center">
.map((supplier, index) => ( <AlertCircle className="w-4 h-4 text-red-500 mr-2" />
<div key={supplier.id} className="flex items-center justify-between"> <span className="text-sm text-[var(--text-primary)]">{alert.product_name || `Alerta ${index + 1}`}</span>
<div className="flex items-center">
<span className="text-sm font-medium text-[var(--text-tertiary)] w-4">
{index + 1}.
</span>
<span className="ml-3 text-sm text-[var(--text-primary)]">{supplier.name}</span>
</div>
<span className="text-sm font-medium text-[var(--text-primary)]">
{supplier.totalSpent.toLocaleString()}
</span>
</div> </div>
))} <span className="text-xs text-red-600 font-medium">
Stock Bajo
</span>
</div>
)) || (
<div className="text-center py-8">
<CheckCircle className="mx-auto h-8 w-8 text-green-500 mb-2" />
<p className="text-sm text-[var(--text-secondary)]">No hay alertas críticas</p>
</div>
)}
</div> </div>
</Card> </Card>
</div> </div>
<Card className="p-6"> <Card className="p-6">
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-4">Gastos por Categoría</h3> <h3 className="text-lg font-semibold text-[var(--text-primary)] mb-4">Resumen de Performance</h3>
<div className="h-64 flex items-center justify-center bg-[var(--bg-secondary)] rounded-lg"> <div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<p className="text-[var(--text-tertiary)]">Gráfico de gastos por categoría</p> <div className="text-center p-4 bg-[var(--bg-secondary)] rounded-lg">
<p className="text-2xl font-bold text-[var(--color-primary)]">{stats.totalPlans}</p>
<p className="text-sm text-[var(--text-secondary)] mt-1">Planes Totales</p>
</div>
<div className="text-center p-4 bg-[var(--bg-secondary)] rounded-lg">
<p className="text-2xl font-bold text-green-600">{stats.activePlans}</p>
<p className="text-sm text-[var(--text-secondary)] mt-1">Planes Activos</p>
</div>
<div className="text-center p-4 bg-[var(--bg-secondary)] rounded-lg">
<p className="text-2xl font-bold text-yellow-600">{stats.pendingRequirements}</p>
<p className="text-sm text-[var(--text-secondary)] mt-1">Pendientes</p>
</div>
<div className="text-center p-4 bg-[var(--bg-secondary)] rounded-lg">
<p className="text-2xl font-bold text-red-600">{stats.criticalRequirements}</p>
<p className="text-sm text-[var(--text-secondary)] mt-1">Críticos</p>
</div>
</div> </div>
</Card> </Card>
</div> </div>
)} )}
{/* Purchase Order Modal */} {/* Procurement Plan Modal */}
{showForm && selectedOrder && ( {showForm && selectedPlan && (
<StatusModal <StatusModal
isOpen={showForm} isOpen={showForm}
onClose={() => { onClose={() => {
setShowForm(false); setShowForm(false);
setSelectedOrder(null); setSelectedPlan(null);
setModalMode('view'); setModalMode('view');
}} }}
mode={modalMode} mode={modalMode}
onModeChange={setModalMode} onModeChange={setModalMode}
title={selectedOrder.supplier} title={`Plan de Compra ${selectedPlan.plan_number}`}
subtitle={`Orden de Compra ${selectedOrder.id}`} subtitle={new Date(selectedPlan.plan_date).toLocaleDateString('es-ES')}
statusIndicator={getPurchaseStatusConfig(selectedOrder.status, selectedOrder.paymentStatus)} statusIndicator={getPlanStatusConfig(selectedPlan.status)}
size="lg" size="lg"
sections={[ sections={[
{ {
title: 'Información del Proveedor', title: 'Información del Plan',
icon: Package, icon: Package,
fields: [ fields: [
{ {
label: 'Proveedor', label: 'Número de Plan',
value: selectedOrder.supplier, value: selectedPlan.plan_number,
highlight: true, highlight: true
editable: true,
required: true,
placeholder: 'Nombre del proveedor'
}, },
{ {
label: 'ID de Orden', label: 'Tipo de Plan',
value: selectedOrder.id value: selectedPlan.plan_type
}, },
{ {
label: 'Estado de Pago', label: 'Estrategia',
value: selectedOrder.paymentStatus === 'paid' ? 'Pagado' : selectedOrder.paymentStatus === 'pending' ? 'Pendiente' : 'Vencido', value: selectedPlan.procurement_strategy
},
{
label: 'Prioridad',
value: selectedPlan.priority,
type: 'status' type: 'status'
} }
] ]
}, },
{ {
title: 'Fechas Importantes', title: 'Fechas y Períodos',
icon: Calendar, icon: Calendar,
fields: [ fields: [
{ {
label: 'Fecha de pedido', label: 'Fecha del Plan',
value: selectedOrder.orderDate, value: selectedPlan.plan_date,
type: 'date', type: 'date'
editable: true
}, },
{ {
label: 'Fecha de entrega', label: 'Período de Inicio',
value: selectedOrder.deliveryDate, value: selectedPlan.plan_period_start,
type: 'date'
},
{
label: 'Período de Fin',
value: selectedPlan.plan_period_end,
type: 'date', type: 'date',
highlight: true, highlight: true
editable: true, },
required: true {
label: 'Horizonte de Planificación',
value: `${selectedPlan.planning_horizon_days} días`
} }
] ]
}, },
@@ -518,47 +530,60 @@ const ProcurementPage: React.FC = () => {
icon: DollarSign, icon: DollarSign,
fields: [ fields: [
{ {
label: 'Importe total', label: 'Costo Estimado Total',
value: selectedOrder.totalAmount, value: selectedPlan.total_estimated_cost,
type: 'currency', type: 'currency',
highlight: true, highlight: true
editable: true,
required: true,
placeholder: '0.00'
}, },
{ {
label: 'Número de artículos', label: 'Costo Aprobado Total',
value: `${selectedOrder.items?.length} productos` value: selectedPlan.total_approved_cost,
type: 'currency'
},
{
label: 'Varianza de Costo',
value: selectedPlan.cost_variance,
type: 'currency'
} }
] ]
}, },
{ {
title: 'Artículos Pedidos', title: 'Estadísticas',
icon: ShoppingCart, icon: ShoppingCart,
fields: [ fields: [
{ {
label: 'Lista de productos', label: 'Total de Requerimientos',
value: selectedOrder.items?.map(item => `${item.name}: ${item.quantity} ${item.unit} - ${formatters.currency(item.total)}`), value: `${selectedPlan.total_requirements} requerimientos`
type: 'list', },
span: 2 {
label: 'Demanda Total (Cantidad)',
value: `${selectedPlan.total_demand_quantity} unidades`
},
{
label: 'Proveedores Primarios',
value: `${selectedPlan.primary_suppliers_count} proveedores`
},
{
label: 'Proveedores de Respaldo',
value: `${selectedPlan.backup_suppliers_count} proveedores`
} }
] ]
}, },
...(selectedOrder.notes ? [{ ...(selectedPlan.special_requirements ? [{
title: 'Notas Adicionales', title: 'Requerimientos Especiales',
fields: [ fields: [
{ {
label: 'Observaciones', label: 'Observaciones',
value: selectedOrder.notes, value: selectedPlan.special_requirements,
span: 2 as const, span: 2 as const,
editable: true, editable: true,
placeholder: 'Añadir notas sobre la orden de compra...' placeholder: 'Añadir requerimientos especiales para el plan...'
} }
] ]
}] : []) }] : [])
]} ]}
onEdit={() => { onEdit={() => {
console.log('Editing purchase order:', selectedOrder.id); console.log('Editing procurement plan:', selectedPlan.id);
}} }}
/> />
)} )}

View File

@@ -65,6 +65,7 @@ app.include_router(tenant.router, prefix="/api/v1/tenants", tags=["tenants"])
app.include_router(notification.router, prefix="/api/v1/notifications", tags=["notifications"]) app.include_router(notification.router, prefix="/api/v1/notifications", tags=["notifications"])
app.include_router(nominatim.router, prefix="/api/v1/nominatim", tags=["location"]) app.include_router(nominatim.router, prefix="/api/v1/nominatim", tags=["location"])
@app.on_event("startup") @app.on_event("startup")
async def startup_event(): async def startup_event():
"""Application startup""" """Application startup"""

View File

@@ -143,6 +143,12 @@ async def proxy_tenant_notifications(request: Request, tenant_id: str = Path(...
# TENANT-SCOPED INVENTORY SERVICE ENDPOINTS # TENANT-SCOPED INVENTORY SERVICE ENDPOINTS
# ================================================================ # ================================================================
@router.api_route("/{tenant_id}/alerts{path:path}", methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"])
async def proxy_tenant_alerts(request: Request, tenant_id: str = Path(...), path: str = ""):
"""Proxy tenant alerts requests to inventory service"""
target_path = f"/api/v1/tenants/{tenant_id}/alerts{path}".rstrip("/")
return await _proxy_to_inventory_service(request, target_path, tenant_id=tenant_id)
@router.api_route("/{tenant_id}/inventory/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"]) @router.api_route("/{tenant_id}/inventory/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"])
async def proxy_tenant_inventory(request: Request, tenant_id: str = Path(...), path: str = ""): async def proxy_tenant_inventory(request: Request, tenant_id: str = Path(...), path: str = ""):
"""Proxy tenant inventory requests to inventory service""" """Proxy tenant inventory requests to inventory service"""
@@ -179,6 +185,18 @@ async def proxy_tenant_orders(request: Request, tenant_id: str = Path(...), path
target_path = f"/api/v1/tenants/{tenant_id}/orders/{path}".rstrip("/") target_path = f"/api/v1/tenants/{tenant_id}/orders/{path}".rstrip("/")
return await _proxy_to_orders_service(request, target_path, tenant_id=tenant_id) return await _proxy_to_orders_service(request, target_path, tenant_id=tenant_id)
@router.api_route("/{tenant_id}/customers/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"])
async def proxy_tenant_customers(request: Request, tenant_id: str = Path(...), path: str = ""):
"""Proxy tenant customers requests to orders service"""
target_path = f"/api/v1/tenants/{tenant_id}/customers/{path}".rstrip("/")
return await _proxy_to_orders_service(request, target_path, tenant_id=tenant_id)
@router.api_route("/{tenant_id}/procurement/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"])
async def proxy_tenant_procurement(request: Request, tenant_id: str = Path(...), path: str = ""):
"""Proxy tenant procurement requests to orders service"""
target_path = f"/api/v1/tenants/{tenant_id}/procurement/{path}".rstrip("/")
return await _proxy_to_orders_service(request, target_path, tenant_id=tenant_id)
# ================================================================ # ================================================================
# TENANT-SCOPED SUPPLIER SERVICE ENDPOINTS # TENANT-SCOPED SUPPLIER SERVICE ENDPOINTS
# ================================================================ # ================================================================

View File

@@ -27,8 +27,8 @@ from shared.clients.forecast_client import ForecastServiceClient
from shared.config.base import BaseServiceSettings from shared.config.base import BaseServiceSettings
from shared.monitoring.decorators import monitor_performance from shared.monitoring.decorators import monitor_performance
# Create router # Create router - tenant-scoped
router = APIRouter(prefix="/procurement-plans", tags=["Procurement Planning"]) router = APIRouter(prefix="/tenants/{tenant_id}", tags=["Procurement Planning"])
# Create service settings # Create service settings
service_settings = BaseServiceSettings() service_settings = BaseServiceSettings()
@@ -82,9 +82,10 @@ async def get_procurement_service(db: AsyncSession = Depends(get_db)) -> Procure
# PROCUREMENT PLAN ENDPOINTS # PROCUREMENT PLAN ENDPOINTS
# ================================================================ # ================================================================
@router.get("/current", response_model=Optional[ProcurementPlanResponse]) @router.get("/procurement/plans/current", response_model=Optional[ProcurementPlanResponse])
@monitor_performance("get_current_procurement_plan") @monitor_performance("get_current_procurement_plan")
async def get_current_procurement_plan( async def get_current_procurement_plan(
tenant_id: uuid.UUID,
tenant_access: TenantAccess = Depends(get_current_tenant), tenant_access: TenantAccess = Depends(get_current_tenant),
procurement_service: ProcurementService = Depends(get_procurement_service) procurement_service: ProcurementService = Depends(get_procurement_service)
): ):
@@ -103,9 +104,10 @@ async def get_current_procurement_plan(
) )
@router.get("/{plan_date}", response_model=Optional[ProcurementPlanResponse]) @router.get("/procurement/plans/date/{plan_date}", response_model=Optional[ProcurementPlanResponse])
@monitor_performance("get_procurement_plan_by_date") @monitor_performance("get_procurement_plan_by_date")
async def get_procurement_plan_by_date( async def get_procurement_plan_by_date(
tenant_id: uuid.UUID,
plan_date: date, plan_date: date,
tenant_access: TenantAccess = Depends(get_current_tenant), tenant_access: TenantAccess = Depends(get_current_tenant),
procurement_service: ProcurementService = Depends(get_procurement_service) procurement_service: ProcurementService = Depends(get_procurement_service)
@@ -125,9 +127,10 @@ async def get_procurement_plan_by_date(
) )
@router.get("/", response_model=PaginatedProcurementPlans) @router.get("/procurement/plans", response_model=PaginatedProcurementPlans)
@monitor_performance("list_procurement_plans") @monitor_performance("list_procurement_plans")
async def list_procurement_plans( async def list_procurement_plans(
tenant_id: uuid.UUID,
status: Optional[str] = Query(None, description="Filter by plan status"), status: Optional[str] = Query(None, description="Filter by plan status"),
start_date: Optional[date] = Query(None, description="Start date filter (YYYY-MM-DD)"), start_date: Optional[date] = Query(None, description="Start date filter (YYYY-MM-DD)"),
end_date: Optional[date] = Query(None, description="End date filter (YYYY-MM-DD)"), end_date: Optional[date] = Query(None, description="End date filter (YYYY-MM-DD)"),
@@ -175,9 +178,10 @@ async def list_procurement_plans(
) )
@router.post("/generate", response_model=GeneratePlanResponse) @router.post("/procurement/plans/generate", response_model=GeneratePlanResponse)
@monitor_performance("generate_procurement_plan") @monitor_performance("generate_procurement_plan")
async def generate_procurement_plan( async def generate_procurement_plan(
tenant_id: uuid.UUID,
request: GeneratePlanRequest, request: GeneratePlanRequest,
tenant_access: TenantAccess = Depends(get_current_tenant), tenant_access: TenantAccess = Depends(get_current_tenant),
procurement_service: ProcurementService = Depends(get_procurement_service) procurement_service: ProcurementService = Depends(get_procurement_service)
@@ -216,9 +220,10 @@ async def generate_procurement_plan(
) )
@router.put("/{plan_id}/status") @router.put("/procurement/plans/{plan_id}/status")
@monitor_performance("update_procurement_plan_status") @monitor_performance("update_procurement_plan_status")
async def update_procurement_plan_status( async def update_procurement_plan_status(
tenant_id: uuid.UUID,
plan_id: uuid.UUID, plan_id: uuid.UUID,
status: str = Query(..., description="New status", pattern="^(draft|pending_approval|approved|in_execution|completed|cancelled)$"), status: str = Query(..., description="New status", pattern="^(draft|pending_approval|approved|in_execution|completed|cancelled)$"),
tenant_access: TenantAccess = Depends(get_current_tenant), tenant_access: TenantAccess = Depends(get_current_tenant),
@@ -254,9 +259,10 @@ async def update_procurement_plan_status(
) )
@router.get("/id/{plan_id}", response_model=Optional[ProcurementPlanResponse]) @router.get("/procurement/plans/id/{plan_id}", response_model=Optional[ProcurementPlanResponse])
@monitor_performance("get_procurement_plan_by_id") @monitor_performance("get_procurement_plan_by_id")
async def get_procurement_plan_by_id( async def get_procurement_plan_by_id(
tenant_id: uuid.UUID,
plan_id: uuid.UUID, plan_id: uuid.UUID,
tenant_access: TenantAccess = Depends(get_current_tenant), tenant_access: TenantAccess = Depends(get_current_tenant),
procurement_service: ProcurementService = Depends(get_procurement_service) procurement_service: ProcurementService = Depends(get_procurement_service)
@@ -290,9 +296,10 @@ async def get_procurement_plan_by_id(
# DASHBOARD ENDPOINTS # DASHBOARD ENDPOINTS
# ================================================================ # ================================================================
@router.get("/dashboard/data", response_model=Optional[DashboardData]) @router.get("/procurement/dashboard", response_model=Optional[DashboardData])
@monitor_performance("get_procurement_dashboard") @monitor_performance("get_procurement_dashboard")
async def get_procurement_dashboard( async def get_procurement_dashboard(
tenant_id: uuid.UUID,
tenant_access: TenantAccess = Depends(get_current_tenant), tenant_access: TenantAccess = Depends(get_current_tenant),
procurement_service: ProcurementService = Depends(get_procurement_service) procurement_service: ProcurementService = Depends(get_procurement_service)
): ):
@@ -321,9 +328,10 @@ async def get_procurement_dashboard(
# REQUIREMENT MANAGEMENT ENDPOINTS # REQUIREMENT MANAGEMENT ENDPOINTS
# ================================================================ # ================================================================
@router.get("/{plan_id}/requirements") @router.get("/procurement/plans/{plan_id}/requirements")
@monitor_performance("get_plan_requirements") @monitor_performance("get_plan_requirements")
async def get_plan_requirements( async def get_plan_requirements(
tenant_id: uuid.UUID,
plan_id: uuid.UUID, plan_id: uuid.UUID,
status: Optional[str] = Query(None, description="Filter by requirement status"), status: Optional[str] = Query(None, description="Filter by requirement status"),
priority: Optional[str] = Query(None, description="Filter by priority level"), priority: Optional[str] = Query(None, description="Filter by priority level"),
@@ -364,9 +372,10 @@ async def get_plan_requirements(
) )
@router.get("/requirements/critical") @router.get("/procurement/requirements/critical")
@monitor_performance("get_critical_requirements") @monitor_performance("get_critical_requirements")
async def get_critical_requirements( async def get_critical_requirements(
tenant_id: uuid.UUID,
tenant_access: TenantAccess = Depends(get_current_tenant), tenant_access: TenantAccess = Depends(get_current_tenant),
procurement_service: ProcurementService = Depends(get_procurement_service) procurement_service: ProcurementService = Depends(get_procurement_service)
): ):
@@ -391,9 +400,10 @@ async def get_critical_requirements(
# UTILITY ENDPOINTS # UTILITY ENDPOINTS
# ================================================================ # ================================================================
@router.post("/scheduler/trigger") @router.post("/procurement/scheduler/trigger")
@monitor_performance("trigger_daily_scheduler") @monitor_performance("trigger_daily_scheduler")
async def trigger_daily_scheduler( async def trigger_daily_scheduler(
tenant_id: uuid.UUID,
tenant_access: TenantAccess = Depends(get_current_tenant), tenant_access: TenantAccess = Depends(get_current_tenant),
procurement_service: ProcurementService = Depends(get_procurement_service) procurement_service: ProcurementService = Depends(get_procurement_service)
): ):
@@ -419,7 +429,7 @@ async def trigger_daily_scheduler(
) )
@router.get("/health") @router.get("/procurement/health")
async def procurement_health_check(): async def procurement_health_check():
""" """
Health check endpoint for procurement service Health check endpoint for procurement service

View File

@@ -21,7 +21,8 @@ class ProcurementPlanRepository(BaseRepository):
"""Repository for procurement plan operations""" """Repository for procurement plan operations"""
def __init__(self, db: AsyncSession): def __init__(self, db: AsyncSession):
super().__init__(db, ProcurementPlan) super().__init__(ProcurementPlan)
self.db = db
async def create_plan(self, plan_data: Dict[str, Any]) -> ProcurementPlan: async def create_plan(self, plan_data: Dict[str, Any]) -> ProcurementPlan:
"""Create a new procurement plan""" """Create a new procurement plan"""
@@ -134,7 +135,8 @@ class ProcurementRequirementRepository(BaseRepository):
"""Repository for procurement requirement operations""" """Repository for procurement requirement operations"""
def __init__(self, db: AsyncSession): def __init__(self, db: AsyncSession):
super().__init__(db, ProcurementRequirement) super().__init__(ProcurementRequirement)
self.db = db
async def create_requirement(self, requirement_data: Dict[str, Any]) -> ProcurementRequirement: async def create_requirement(self, requirement_data: Dict[str, Any]) -> ProcurementRequirement:
"""Create a new procurement requirement""" """Create a new procurement requirement"""

View File

@@ -36,7 +36,7 @@ class CacheService:
self.redis_url, self.redis_url,
decode_responses=True, decode_responses=True,
socket_keepalive=True, socket_keepalive=True,
socket_keepalive_options={"TCP_KEEPIDLE": 1, "TCP_KEEPINTVL": 3, "TCP_KEEPCNT": 5}, socket_keepalive_options={1: 1, 3: 3, 5: 5}, # Use integer keys
retry_on_timeout=True, retry_on_timeout=True,
max_connections=50 max_connections=50
) )