Add fixes to procurement logic and fix rel-time connections
This commit is contained in:
@@ -29,6 +29,9 @@ import {
|
||||
GeneratePlanResponse,
|
||||
PaginatedProcurementPlans,
|
||||
GetProcurementPlansParams,
|
||||
CreatePOsResult,
|
||||
LinkRequirementToPORequest,
|
||||
UpdateDeliveryStatusRequest,
|
||||
GetPlanRequirementsParams,
|
||||
UpdatePlanStatusParams,
|
||||
} from '../types/orders';
|
||||
@@ -546,3 +549,186 @@ export const useTriggerDailyScheduler = (
|
||||
});
|
||||
};
|
||||
|
||||
// ===== NEW PROCUREMENT FEATURE HOOKS =====
|
||||
|
||||
/**
|
||||
* Hook to recalculate a procurement plan
|
||||
*/
|
||||
export const useRecalculateProcurementPlan = (
|
||||
options?: UseMutationOptions<GeneratePlanResponse, ApiError, { tenantId: string; planId: string }>
|
||||
) => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<GeneratePlanResponse, ApiError, { tenantId: string; planId: string }>({
|
||||
mutationFn: ({ tenantId, planId }) => OrdersService.recalculateProcurementPlan(tenantId, planId),
|
||||
onSuccess: (data, variables) => {
|
||||
if (data.plan) {
|
||||
// Update the specific plan in cache
|
||||
queryClient.setQueryData(
|
||||
ordersKeys.procurementPlan(variables.tenantId, variables.planId),
|
||||
data.plan
|
||||
);
|
||||
}
|
||||
|
||||
// Invalidate plans list and dashboard
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ordersKeys.procurement(),
|
||||
predicate: (query) => {
|
||||
return JSON.stringify(query.queryKey).includes(variables.tenantId);
|
||||
},
|
||||
});
|
||||
},
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to approve a procurement plan
|
||||
*/
|
||||
export const useApproveProcurementPlan = (
|
||||
options?: UseMutationOptions<ProcurementPlanResponse, ApiError, { tenantId: string; planId: string; approval_notes?: string }>
|
||||
) => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<ProcurementPlanResponse, ApiError, { tenantId: string; planId: string; approval_notes?: string }>({
|
||||
mutationFn: ({ tenantId, planId, approval_notes }) =>
|
||||
OrdersService.approveProcurementPlan(tenantId, planId, { approval_notes }),
|
||||
onSuccess: (data, variables) => {
|
||||
// Update the specific plan in cache
|
||||
queryClient.setQueryData(
|
||||
ordersKeys.procurementPlan(variables.tenantId, variables.planId),
|
||||
data
|
||||
);
|
||||
|
||||
// Invalidate plans list and dashboard
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ordersKeys.procurement(),
|
||||
predicate: (query) => {
|
||||
return JSON.stringify(query.queryKey).includes(variables.tenantId);
|
||||
},
|
||||
});
|
||||
},
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to reject a procurement plan
|
||||
*/
|
||||
export const useRejectProcurementPlan = (
|
||||
options?: UseMutationOptions<ProcurementPlanResponse, ApiError, { tenantId: string; planId: string; rejection_notes?: string }>
|
||||
) => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<ProcurementPlanResponse, ApiError, { tenantId: string; planId: string; rejection_notes?: string }>({
|
||||
mutationFn: ({ tenantId, planId, rejection_notes }) =>
|
||||
OrdersService.rejectProcurementPlan(tenantId, planId, { rejection_notes }),
|
||||
onSuccess: (data, variables) => {
|
||||
// Update the specific plan in cache
|
||||
queryClient.setQueryData(
|
||||
ordersKeys.procurementPlan(variables.tenantId, variables.planId),
|
||||
data
|
||||
);
|
||||
|
||||
// Invalidate plans list and dashboard
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ordersKeys.procurement(),
|
||||
predicate: (query) => {
|
||||
return JSON.stringify(query.queryKey).includes(variables.tenantId);
|
||||
},
|
||||
});
|
||||
},
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to create purchase orders from procurement plan
|
||||
*/
|
||||
export const useCreatePurchaseOrdersFromPlan = (
|
||||
options?: UseMutationOptions<CreatePOsResult, ApiError, { tenantId: string; planId: string; autoApprove?: boolean }>
|
||||
) => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<CreatePOsResult, ApiError, { tenantId: string; planId: string; autoApprove?: boolean }>({
|
||||
mutationFn: ({ tenantId, planId, autoApprove = false }) =>
|
||||
OrdersService.createPurchaseOrdersFromPlan(tenantId, planId, autoApprove),
|
||||
onSuccess: (data, variables) => {
|
||||
// Invalidate procurement plan to refresh requirements status
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ordersKeys.procurementPlan(variables.tenantId, variables.planId),
|
||||
});
|
||||
|
||||
// Invalidate dashboard
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ordersKeys.procurementDashboard(variables.tenantId),
|
||||
});
|
||||
},
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to link a requirement to a purchase order
|
||||
*/
|
||||
export const useLinkRequirementToPurchaseOrder = (
|
||||
options?: UseMutationOptions<
|
||||
{ success: boolean; message: string; requirement_id: string; purchase_order_id: string },
|
||||
ApiError,
|
||||
{ tenantId: string; requirementId: string; request: LinkRequirementToPORequest }
|
||||
>
|
||||
) => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<
|
||||
{ success: boolean; message: string; requirement_id: string; purchase_order_id: string },
|
||||
ApiError,
|
||||
{ tenantId: string; requirementId: string; request: LinkRequirementToPORequest }
|
||||
>({
|
||||
mutationFn: ({ tenantId, requirementId, request }) =>
|
||||
OrdersService.linkRequirementToPurchaseOrder(tenantId, requirementId, request),
|
||||
onSuccess: (data, variables) => {
|
||||
// Invalidate procurement data to refresh requirements
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ordersKeys.procurement(),
|
||||
predicate: (query) => {
|
||||
return JSON.stringify(query.queryKey).includes(variables.tenantId);
|
||||
},
|
||||
});
|
||||
},
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to update delivery status for a requirement
|
||||
*/
|
||||
export const useUpdateRequirementDeliveryStatus = (
|
||||
options?: UseMutationOptions<
|
||||
{ success: boolean; message: string; requirement_id: string; delivery_status: string },
|
||||
ApiError,
|
||||
{ tenantId: string; requirementId: string; request: UpdateDeliveryStatusRequest }
|
||||
>
|
||||
) => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<
|
||||
{ success: boolean; message: string; requirement_id: string; delivery_status: string },
|
||||
ApiError,
|
||||
{ tenantId: string; requirementId: string; request: UpdateDeliveryStatusRequest }
|
||||
>({
|
||||
mutationFn: ({ tenantId, requirementId, request }) =>
|
||||
OrdersService.updateRequirementDeliveryStatus(tenantId, requirementId, request),
|
||||
onSuccess: (data, variables) => {
|
||||
// Invalidate procurement data to refresh requirements
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ordersKeys.procurement(),
|
||||
predicate: (query) => {
|
||||
return JSON.stringify(query.queryKey).includes(variables.tenantId);
|
||||
},
|
||||
});
|
||||
},
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -661,6 +661,12 @@ export {
|
||||
useGenerateProcurementPlan,
|
||||
useUpdateProcurementPlanStatus,
|
||||
useTriggerDailyScheduler,
|
||||
useRecalculateProcurementPlan,
|
||||
useApproveProcurementPlan,
|
||||
useRejectProcurementPlan,
|
||||
useCreatePurchaseOrdersFromPlan,
|
||||
useLinkRequirementToPurchaseOrder,
|
||||
useUpdateRequirementDeliveryStatus,
|
||||
ordersKeys,
|
||||
} from './hooks/orders';
|
||||
|
||||
|
||||
@@ -34,6 +34,11 @@ import {
|
||||
GetProcurementPlansParams,
|
||||
GetPlanRequirementsParams,
|
||||
UpdatePlanStatusParams,
|
||||
CreatePOsResult,
|
||||
LinkRequirementToPORequest,
|
||||
UpdateDeliveryStatusRequest,
|
||||
ApprovalRequest,
|
||||
RejectionRequest,
|
||||
} from '../types/orders';
|
||||
|
||||
export class OrdersService {
|
||||
@@ -303,6 +308,82 @@ export class OrdersService {
|
||||
return apiClient.get<{ status: string; service: string; procurement_enabled: boolean; timestamp: string }>(`/tenants/${tenantId}/procurement/health`);
|
||||
}
|
||||
|
||||
// ===== NEW PROCUREMENT FEATURES =====
|
||||
|
||||
/**
|
||||
* Recalculate an existing procurement plan
|
||||
* POST /tenants/{tenant_id}/procurement/plans/{plan_id}/recalculate
|
||||
*/
|
||||
static async recalculateProcurementPlan(tenantId: string, planId: string): Promise<GeneratePlanResponse> {
|
||||
return apiClient.post<GeneratePlanResponse>(
|
||||
`/tenants/${tenantId}/procurement/plans/${planId}/recalculate`,
|
||||
{}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Approve a procurement plan with notes
|
||||
* POST /tenants/{tenant_id}/procurement/plans/{plan_id}/approve
|
||||
*/
|
||||
static async approveProcurementPlan(tenantId: string, planId: string, request?: ApprovalRequest): Promise<ProcurementPlanResponse> {
|
||||
return apiClient.post<ProcurementPlanResponse>(
|
||||
`/tenants/${tenantId}/procurement/plans/${planId}/approve`,
|
||||
request || {}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject a procurement plan with notes
|
||||
* POST /tenants/{tenant_id}/procurement/plans/{plan_id}/reject
|
||||
*/
|
||||
static async rejectProcurementPlan(tenantId: string, planId: string, request?: RejectionRequest): Promise<ProcurementPlanResponse> {
|
||||
return apiClient.post<ProcurementPlanResponse>(
|
||||
`/tenants/${tenantId}/procurement/plans/${planId}/reject`,
|
||||
request || {}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create purchase orders automatically from procurement plan
|
||||
* POST /tenants/{tenant_id}/procurement/plans/{plan_id}/create-purchase-orders
|
||||
*/
|
||||
static async createPurchaseOrdersFromPlan(tenantId: string, planId: string, autoApprove: boolean = false): Promise<CreatePOsResult> {
|
||||
return apiClient.post<CreatePOsResult>(
|
||||
`/tenants/${tenantId}/procurement/plans/${planId}/create-purchase-orders`,
|
||||
{ auto_approve: autoApprove }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Link a procurement requirement to a purchase order
|
||||
* POST /tenants/{tenant_id}/procurement/requirements/{requirement_id}/link-purchase-order
|
||||
*/
|
||||
static async linkRequirementToPurchaseOrder(
|
||||
tenantId: string,
|
||||
requirementId: string,
|
||||
request: LinkRequirementToPORequest
|
||||
): Promise<{ success: boolean; message: string; requirement_id: string; purchase_order_id: string }> {
|
||||
return apiClient.post<{ success: boolean; message: string; requirement_id: string; purchase_order_id: string }>(
|
||||
`/tenants/${tenantId}/procurement/requirements/${requirementId}/link-purchase-order`,
|
||||
request
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update delivery status for a requirement
|
||||
* PUT /tenants/{tenant_id}/procurement/requirements/{requirement_id}/delivery-status
|
||||
*/
|
||||
static async updateRequirementDeliveryStatus(
|
||||
tenantId: string,
|
||||
requirementId: string,
|
||||
request: UpdateDeliveryStatusRequest
|
||||
): Promise<{ success: boolean; message: string; requirement_id: string; delivery_status: string }> {
|
||||
return apiClient.put<{ success: boolean; message: string; requirement_id: string; delivery_status: string }>(
|
||||
`/tenants/${tenantId}/procurement/requirements/${requirementId}/delivery-status`,
|
||||
request
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default OrdersService;
|
||||
@@ -479,6 +479,14 @@ export interface ProcurementPlanUpdate {
|
||||
seasonal_adjustments?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface ApprovalWorkflowEntry {
|
||||
timestamp: string;
|
||||
from_status: string;
|
||||
to_status: string;
|
||||
user_id?: string;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface ProcurementPlanResponse extends ProcurementPlanBase {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
@@ -502,6 +510,7 @@ export interface ProcurementPlanResponse extends ProcurementPlanBase {
|
||||
on_time_delivery_rate?: number;
|
||||
cost_accuracy?: number;
|
||||
quality_score?: number;
|
||||
approval_workflow?: ApprovalWorkflowEntry[];
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
created_by?: string;
|
||||
@@ -551,6 +560,46 @@ export interface GeneratePlanResponse {
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
// New Feature Types
|
||||
export interface CreatePOsResult {
|
||||
success: boolean;
|
||||
created_pos: {
|
||||
po_id: string;
|
||||
po_number: string;
|
||||
supplier_id: string;
|
||||
items_count: number;
|
||||
total_amount: number;
|
||||
}[];
|
||||
failed_pos: {
|
||||
supplier_id: string;
|
||||
error: string;
|
||||
}[];
|
||||
total_created: number;
|
||||
total_failed: number;
|
||||
}
|
||||
|
||||
export interface LinkRequirementToPORequest {
|
||||
purchase_order_id: string;
|
||||
purchase_order_number: string;
|
||||
ordered_quantity: number;
|
||||
expected_delivery_date?: string;
|
||||
}
|
||||
|
||||
export interface UpdateDeliveryStatusRequest {
|
||||
delivery_status: string;
|
||||
received_quantity?: number;
|
||||
actual_delivery_date?: string;
|
||||
quality_rating?: number;
|
||||
}
|
||||
|
||||
export interface ApprovalRequest {
|
||||
approval_notes?: string;
|
||||
}
|
||||
|
||||
export interface RejectionRequest {
|
||||
rejection_notes?: string;
|
||||
}
|
||||
|
||||
export interface PaginatedProcurementPlans {
|
||||
plans: ProcurementPlanResponse[];
|
||||
total: number;
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
import React from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Card, CardHeader, CardBody } from '../../ui/Card';
|
||||
import { Button } from '../../ui/Button';
|
||||
import {
|
||||
FileText,
|
||||
CheckCircle,
|
||||
Clock,
|
||||
Truck,
|
||||
AlertCircle,
|
||||
ChevronRight,
|
||||
Euro,
|
||||
Calendar,
|
||||
Package
|
||||
} from 'lucide-react';
|
||||
import { useProcurementDashboard } from '../../../api/hooks/orders';
|
||||
import { useCurrentTenant } from '../../../stores/tenant.store';
|
||||
|
||||
const PurchaseOrdersTracking: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const currentTenant = useCurrentTenant();
|
||||
const tenantId = currentTenant?.id || '';
|
||||
|
||||
const { data: dashboard, isLoading } = useProcurementDashboard(tenantId);
|
||||
|
||||
const getStatusIcon = (status: string) => {
|
||||
switch (status) {
|
||||
case 'draft':
|
||||
return <Clock className="w-4 h-4" />;
|
||||
case 'pending_approval':
|
||||
return <AlertCircle className="w-4 h-4" />;
|
||||
case 'approved':
|
||||
return <CheckCircle className="w-4 h-4" />;
|
||||
case 'in_execution':
|
||||
return <Truck className="w-4 h-4" />;
|
||||
case 'completed':
|
||||
return <CheckCircle className="w-4 h-4" />;
|
||||
default:
|
||||
return <FileText className="w-4 h-4" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'draft':
|
||||
return 'text-[var(--text-tertiary)] bg-[var(--bg-tertiary)]';
|
||||
case 'pending_approval':
|
||||
return 'text-yellow-700 bg-yellow-100';
|
||||
case 'approved':
|
||||
return 'text-green-700 bg-green-100';
|
||||
case 'in_execution':
|
||||
return 'text-blue-700 bg-blue-100';
|
||||
case 'completed':
|
||||
return 'text-green-700 bg-green-100';
|
||||
case 'cancelled':
|
||||
return 'text-red-700 bg-red-100';
|
||||
default:
|
||||
return 'text-[var(--text-secondary)] bg-[var(--bg-secondary)]';
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusLabel = (status: string) => {
|
||||
const labels: Record<string, string> = {
|
||||
draft: 'Borrador',
|
||||
pending_approval: 'Pendiente Aprobación',
|
||||
approved: 'Aprobado',
|
||||
in_execution: 'En Ejecución',
|
||||
completed: 'Completado',
|
||||
cancelled: 'Cancelado'
|
||||
};
|
||||
return labels[status] || status;
|
||||
};
|
||||
|
||||
const handleViewAllPOs = () => {
|
||||
navigate('/app/operations/procurement');
|
||||
};
|
||||
|
||||
const handleViewPODetails = (planId: string) => {
|
||||
navigate(`/app/operations/procurement?plan=${planId}`);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<FileText className="w-5 h-5 text-[var(--color-primary)]" />
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)]">Órdenes de Compra</h3>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-[var(--text-secondary)] mt-1">Seguimiento de órdenes de compra</p>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-[var(--color-primary)]"></div>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const recentPlans = dashboard?.recent_plans || [];
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<FileText className="w-5 h-5 text-[var(--color-primary)]" />
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)]">Órdenes de Compra</h3>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleViewAllPOs}
|
||||
className="text-[var(--color-primary)] hover:text-[var(--color-primary)]/80"
|
||||
>
|
||||
Ver Todas
|
||||
<ChevronRight className="w-4 h-4 ml-1" />
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-sm text-[var(--text-secondary)] mt-1">Seguimiento de órdenes de compra</p>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
{recentPlans.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<Package className="w-12 h-12 text-[var(--text-tertiary)] mx-auto mb-3" />
|
||||
<p className="text-[var(--text-secondary)]">No hay órdenes de compra recientes</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleViewAllPOs}
|
||||
className="mt-4"
|
||||
>
|
||||
Crear Plan de Compras
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{recentPlans.slice(0, 5).map((plan: any) => (
|
||||
<div
|
||||
key={plan.id}
|
||||
className="flex items-center justify-between p-4 bg-[var(--bg-secondary)] rounded-lg hover:bg-[var(--bg-tertiary)] transition-colors cursor-pointer"
|
||||
onClick={() => handleViewPODetails(plan.id)}
|
||||
>
|
||||
<div className="flex items-start gap-3 flex-1">
|
||||
<div className={`p-2 rounded-lg ${getStatusColor(plan.status)}`}>
|
||||
{getStatusIcon(plan.status)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="font-medium text-[var(--text-primary)]">
|
||||
{plan.plan_number}
|
||||
</span>
|
||||
<span className={`px-2 py-0.5 rounded-full text-xs ${getStatusColor(plan.status)}`}>
|
||||
{getStatusLabel(plan.status)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-sm text-[var(--text-secondary)]">
|
||||
<div className="flex items-center gap-1">
|
||||
<Calendar className="w-3.5 h-3.5" />
|
||||
<span>{new Date(plan.plan_date).toLocaleDateString('es-ES')}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Package className="w-3.5 h-3.5" />
|
||||
<span>{plan.total_requirements} items</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Euro className="w-3.5 h-3.5" />
|
||||
<span>€{plan.total_estimated_cost?.toFixed(2) || '0.00'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ChevronRight className="w-5 h-5 text-[var(--text-tertiary)] flex-shrink-0" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Summary Stats */}
|
||||
{dashboard?.stats && (
|
||||
<div className="grid grid-cols-3 gap-4 mt-4 pt-4 border-t border-[var(--border-primary)]">
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-[var(--text-primary)]">
|
||||
{dashboard.stats.total_plans || 0}
|
||||
</div>
|
||||
<div className="text-xs text-[var(--text-secondary)] mt-1">Total Planes</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-[var(--color-success)]">
|
||||
{dashboard.stats.approved_plans || 0}
|
||||
</div>
|
||||
<div className="text-xs text-[var(--text-secondary)] mt-1">Aprobados</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-[var(--color-warning)]">
|
||||
{dashboard.stats.pending_plans || 0}
|
||||
</div>
|
||||
<div className="text-xs text-[var(--text-secondary)] mt-1">Pendientes</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default PurchaseOrdersTracking;
|
||||
@@ -4,6 +4,7 @@
|
||||
export { default as RealTimeAlerts } from './RealTimeAlerts';
|
||||
export { default as ProcurementPlansToday } from './ProcurementPlansToday';
|
||||
export { default as ProductionPlansToday } from './ProductionPlansToday';
|
||||
export { default as PurchaseOrdersTracking } from './PurchaseOrdersTracking';
|
||||
|
||||
// Production Management Dashboard Widgets
|
||||
export { default as ProductionCostMonitor } from './ProductionCostMonitor';
|
||||
|
||||
@@ -53,17 +53,21 @@ export const SSEProvider: React.FC<SSEProviderProps> = ({ children }) => {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get tenant ID from store - no fallback
|
||||
const tenantId = currentTenant?.id;
|
||||
|
||||
if (!tenantId) {
|
||||
if (!currentTenant?.id) {
|
||||
console.log('No tenant ID available, skipping SSE connection');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Connect to notification service SSE endpoint with token
|
||||
const eventSource = new EventSource(`http://localhost:8006/api/v1/sse/alerts/stream/${tenantId}?token=${encodeURIComponent(token)}`, {
|
||||
// Connect to gateway SSE endpoint with token and tenant_id
|
||||
// Use same protocol and host as the current page to avoid CORS and mixed content issues
|
||||
const protocol = window.location.protocol;
|
||||
const host = window.location.host;
|
||||
const sseUrl = `${protocol}//${host}/api/events?token=${encodeURIComponent(token)}&tenant_id=${currentTenant.id}`;
|
||||
|
||||
console.log('Connecting to SSE endpoint:', sseUrl);
|
||||
|
||||
const eventSource = new EventSource(sseUrl, {
|
||||
withCredentials: true,
|
||||
});
|
||||
|
||||
@@ -133,39 +137,23 @@ export const SSEProvider: React.FC<SSEProviderProps> = ({ children }) => {
|
||||
}
|
||||
};
|
||||
|
||||
// Handle connection confirmation from notification service
|
||||
eventSource.addEventListener('connected', (event) => {
|
||||
// Handle connection confirmation from gateway
|
||||
eventSource.addEventListener('connection', (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
console.log('SSE connection confirmed:', data);
|
||||
} catch (error) {
|
||||
console.error('Error parsing connected event:', error);
|
||||
console.error('Error parsing connection event:', error);
|
||||
}
|
||||
});
|
||||
|
||||
// Handle ping events (keepalive)
|
||||
eventSource.addEventListener('ping', (event) => {
|
||||
// Handle heartbeat events (keepalive)
|
||||
eventSource.addEventListener('heartbeat', (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
console.log('SSE ping received:', data.timestamp);
|
||||
console.log('SSE heartbeat received:', data.timestamp);
|
||||
} catch (error) {
|
||||
console.error('Error parsing ping event:', error);
|
||||
}
|
||||
});
|
||||
|
||||
// Handle initial items
|
||||
eventSource.addEventListener('initial_items', (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
console.log('Initial items received:', data);
|
||||
|
||||
// Trigger listeners for initial data
|
||||
const listeners = eventListenersRef.current.get('initial_items');
|
||||
if (listeners) {
|
||||
listeners.forEach(callback => callback(data));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error parsing initial_items event:', error);
|
||||
console.error('Error parsing heartbeat event:', error);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -235,6 +223,80 @@ export const SSEProvider: React.FC<SSEProviderProps> = ({ children }) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Handle inventory_alert events (high/urgent severity alerts from gateway)
|
||||
eventSource.addEventListener('inventory_alert', (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
const sseEvent: SSEEvent = {
|
||||
type: 'alert',
|
||||
data,
|
||||
timestamp: data.timestamp || new Date().toISOString(),
|
||||
};
|
||||
|
||||
setLastEvent(sseEvent);
|
||||
|
||||
// Show urgent alert toast
|
||||
const toastType = data.severity === 'urgent' ? 'error' : 'error';
|
||||
|
||||
showToast({
|
||||
type: toastType,
|
||||
title: data.title || 'Alerta de Inventario',
|
||||
message: data.message,
|
||||
duration: data.severity === 'urgent' ? 0 : 5000,
|
||||
});
|
||||
|
||||
// Trigger alert listeners
|
||||
const listeners = eventListenersRef.current.get('alert');
|
||||
if (listeners) {
|
||||
listeners.forEach(callback => callback(data));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error parsing inventory_alert event:', error);
|
||||
}
|
||||
});
|
||||
|
||||
// Handle generic notification events from gateway
|
||||
eventSource.addEventListener('notification', (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
const sseEvent: SSEEvent = {
|
||||
type: data.item_type || 'notification',
|
||||
data,
|
||||
timestamp: data.timestamp || new Date().toISOString(),
|
||||
};
|
||||
|
||||
setLastEvent(sseEvent);
|
||||
|
||||
// Show notification toast
|
||||
let toastType: 'info' | 'success' | 'warning' | 'error' = 'info';
|
||||
if (data.severity === 'urgent') toastType = 'error';
|
||||
else if (data.severity === 'high') toastType = 'warning';
|
||||
else if (data.severity === 'medium') toastType = 'info';
|
||||
|
||||
showToast({
|
||||
type: toastType,
|
||||
title: data.title || 'Notificación',
|
||||
message: data.message,
|
||||
duration: data.severity === 'urgent' ? 0 : 5000,
|
||||
});
|
||||
|
||||
// Trigger listeners for both notification and specific type
|
||||
const notificationListeners = eventListenersRef.current.get('notification');
|
||||
if (notificationListeners) {
|
||||
notificationListeners.forEach(callback => callback(data));
|
||||
}
|
||||
|
||||
if (data.item_type) {
|
||||
const typeListeners = eventListenersRef.current.get(data.item_type);
|
||||
if (typeListeners) {
|
||||
typeListeners.forEach(callback => callback(data));
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error parsing notification event:', error);
|
||||
}
|
||||
});
|
||||
|
||||
eventSource.onerror = (error) => {
|
||||
console.error('SSE connection error:', error);
|
||||
setIsConnected(false);
|
||||
|
||||
@@ -8,6 +8,7 @@ import StatsGrid from '../../components/ui/Stats/StatsGrid';
|
||||
import RealTimeAlerts from '../../components/domain/dashboard/RealTimeAlerts';
|
||||
import ProcurementPlansToday from '../../components/domain/dashboard/ProcurementPlansToday';
|
||||
import ProductionPlansToday from '../../components/domain/dashboard/ProductionPlansToday';
|
||||
import PurchaseOrdersTracking from '../../components/domain/dashboard/PurchaseOrdersTracking';
|
||||
import { useTenant } from '../../stores/tenant.store';
|
||||
import {
|
||||
AlertTriangle,
|
||||
@@ -154,14 +155,17 @@ const DashboardPage: React.FC = () => {
|
||||
{/* 1. Real-time alerts block */}
|
||||
<RealTimeAlerts />
|
||||
|
||||
{/* 2. Procurement plans block */}
|
||||
{/* 2. Purchase Orders Tracking block */}
|
||||
<PurchaseOrdersTracking />
|
||||
|
||||
{/* 3. Procurement plans block */}
|
||||
<ProcurementPlansToday
|
||||
onOrderItem={handleOrderItem}
|
||||
onViewDetails={handleViewDetails}
|
||||
onViewAllPlans={handleViewAllPlans}
|
||||
/>
|
||||
|
||||
{/* 3. Production plans block */}
|
||||
{/* 4. Production plans block */}
|
||||
<ProductionPlansToday
|
||||
onStartOrder={handleStartOrder}
|
||||
onPauseOrder={handlePauseOrder}
|
||||
|
||||
472
frontend/src/pages/app/analytics/ProcurementAnalyticsPage.tsx
Normal file
472
frontend/src/pages/app/analytics/ProcurementAnalyticsPage.tsx
Normal file
@@ -0,0 +1,472 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
ShoppingCart,
|
||||
TrendingUp,
|
||||
AlertCircle,
|
||||
Target,
|
||||
DollarSign,
|
||||
Award,
|
||||
Lock,
|
||||
BarChart3,
|
||||
Package,
|
||||
Truck,
|
||||
Calendar
|
||||
} from 'lucide-react';
|
||||
import { PageHeader } from '../../../components/layout';
|
||||
import { Card, StatsGrid, Button, Tabs } from '../../../components/ui';
|
||||
import { useSubscription } from '../../../api/hooks/subscription';
|
||||
import { useCurrentTenant } from '../../../stores/tenant.store';
|
||||
import { useProcurementDashboard } from '../../../api/hooks/orders';
|
||||
import { formatters } from '../../../components/ui/Stats/StatsPresets';
|
||||
|
||||
const ProcurementAnalyticsPage: React.FC = () => {
|
||||
const { canAccessAnalytics } = useSubscription();
|
||||
const currentTenant = useCurrentTenant();
|
||||
const tenantId = currentTenant?.id || '';
|
||||
|
||||
const [activeTab, setActiveTab] = useState('overview');
|
||||
|
||||
const { data: dashboard, isLoading: dashboardLoading } = useProcurementDashboard(tenantId);
|
||||
|
||||
// Check if user has access to advanced analytics (professional/enterprise)
|
||||
const hasAdvancedAccess = canAccessAnalytics('advanced');
|
||||
|
||||
// If user doesn't have access to advanced analytics, show upgrade message
|
||||
if (!hasAdvancedAccess) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="Analítica de Compras"
|
||||
description="Insights avanzados sobre planificación de compras y gestión de proveedores"
|
||||
/>
|
||||
|
||||
<Card className="p-8 text-center">
|
||||
<Lock className="mx-auto h-12 w-12 text-[var(--text-tertiary)] mb-4" />
|
||||
<h3 className="text-lg font-medium text-[var(--text-primary)] mb-2">
|
||||
Funcionalidad Exclusiva para Profesionales y Empresas
|
||||
</h3>
|
||||
<p className="text-[var(--text-secondary)] mb-4">
|
||||
La analítica avanzada de compras está disponible solo para planes Professional y Enterprise.
|
||||
Actualiza tu plan para acceder a análisis detallados de proveedores, optimización de costos y métricas de rendimiento.
|
||||
</p>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="md"
|
||||
onClick={() => window.location.hash = '#/app/settings/profile'}
|
||||
>
|
||||
Actualizar Plan
|
||||
</Button>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Tab configuration
|
||||
const tabs = [
|
||||
{
|
||||
id: 'overview',
|
||||
label: 'Resumen',
|
||||
icon: BarChart3
|
||||
},
|
||||
{
|
||||
id: 'performance',
|
||||
label: 'Rendimiento',
|
||||
icon: TrendingUp
|
||||
},
|
||||
{
|
||||
id: 'suppliers',
|
||||
label: 'Proveedores',
|
||||
icon: Truck
|
||||
},
|
||||
{
|
||||
id: 'costs',
|
||||
label: 'Costos',
|
||||
icon: DollarSign
|
||||
},
|
||||
{
|
||||
id: 'quality',
|
||||
label: 'Calidad',
|
||||
icon: Award
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="Analítica de Compras"
|
||||
description="Insights avanzados sobre planificación de compras y gestión de proveedores"
|
||||
/>
|
||||
|
||||
{/* Summary Stats */}
|
||||
<StatsGrid
|
||||
stats={[
|
||||
{
|
||||
label: 'Planes Activos',
|
||||
value: dashboard?.stats?.total_plans || 0,
|
||||
icon: ShoppingCart,
|
||||
formatter: formatters.number
|
||||
},
|
||||
{
|
||||
label: 'Tasa de Cumplimiento',
|
||||
value: dashboard?.stats?.avg_fulfillment_rate || 0,
|
||||
icon: Target,
|
||||
formatter: formatters.percentage,
|
||||
change: dashboard?.stats?.fulfillment_trend
|
||||
},
|
||||
{
|
||||
label: 'Entregas a Tiempo',
|
||||
value: dashboard?.stats?.avg_on_time_delivery || 0,
|
||||
icon: Calendar,
|
||||
formatter: formatters.percentage,
|
||||
change: dashboard?.stats?.on_time_trend
|
||||
},
|
||||
{
|
||||
label: 'Variación de Costos',
|
||||
value: dashboard?.stats?.avg_cost_variance || 0,
|
||||
icon: DollarSign,
|
||||
formatter: formatters.percentage,
|
||||
change: dashboard?.stats?.cost_variance_trend
|
||||
}
|
||||
]}
|
||||
loading={dashboardLoading}
|
||||
/>
|
||||
|
||||
{/* Tabs */}
|
||||
<Tabs
|
||||
items={tabs}
|
||||
activeTab={activeTab}
|
||||
onTabChange={setActiveTab}
|
||||
/>
|
||||
|
||||
{/* Tab Content */}
|
||||
<div className="space-y-6">
|
||||
{activeTab === 'overview' && (
|
||||
<>
|
||||
{/* Overview Tab */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* Plan Status Distribution */}
|
||||
<Card>
|
||||
<div className="p-6">
|
||||
<h3 className="text-lg font-medium text-[var(--text-primary)] mb-4">
|
||||
Distribución de Estados de Planes
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
{dashboard?.plan_status_distribution?.map((status: any) => (
|
||||
<div key={status.status} className="flex items-center justify-between">
|
||||
<span className="text-[var(--text-secondary)]">{status.status}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-32 h-2 bg-[var(--bg-tertiary)] rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-[var(--color-primary)]"
|
||||
style={{ width: `${(status.count / dashboard.stats.total_plans) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-sm font-medium text-[var(--text-primary)] w-8 text-right">
|
||||
{status.count}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Critical Requirements */}
|
||||
<Card>
|
||||
<div className="p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-medium text-[var(--text-primary)]">
|
||||
Requerimientos Críticos
|
||||
</h3>
|
||||
<AlertCircle className="h-5 w-5 text-[var(--color-error)]" />
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-[var(--text-secondary)]">Stock Crítico</span>
|
||||
<span className="text-2xl font-bold text-[var(--color-error)]">
|
||||
{dashboard?.critical_requirements?.low_stock || 0}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-[var(--text-secondary)]">Entregas Atrasadas</span>
|
||||
<span className="text-2xl font-bold text-[var(--color-warning)]">
|
||||
{dashboard?.critical_requirements?.overdue || 0}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-[var(--text-secondary)]">Alta Prioridad</span>
|
||||
<span className="text-2xl font-bold text-[var(--color-info)]">
|
||||
{dashboard?.critical_requirements?.high_priority || 0}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Recent Plans */}
|
||||
<Card>
|
||||
<div className="p-6">
|
||||
<h3 className="text-lg font-medium text-[var(--text-primary)] mb-4">
|
||||
Planes Recientes
|
||||
</h3>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-[var(--border-primary)]">
|
||||
<th className="text-left py-3 px-4 text-[var(--text-secondary)] font-medium">Plan</th>
|
||||
<th className="text-left py-3 px-4 text-[var(--text-secondary)] font-medium">Fecha</th>
|
||||
<th className="text-left py-3 px-4 text-[var(--text-secondary)] font-medium">Estado</th>
|
||||
<th className="text-right py-3 px-4 text-[var(--text-secondary)] font-medium">Requerimientos</th>
|
||||
<th className="text-right py-3 px-4 text-[var(--text-secondary)] font-medium">Costo Total</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{dashboard?.recent_plans?.map((plan: any) => (
|
||||
<tr key={plan.id} className="border-b border-[var(--border-primary)] hover:bg-[var(--bg-secondary)]">
|
||||
<td className="py-3 px-4 text-[var(--text-primary)]">{plan.plan_number}</td>
|
||||
<td className="py-3 px-4 text-[var(--text-secondary)]">
|
||||
{new Date(plan.plan_date).toLocaleDateString()}
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<span className={`px-2 py-1 rounded-full text-xs ${getStatusColor(plan.status)}`}>
|
||||
{plan.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3 px-4 text-right text-[var(--text-primary)]">
|
||||
{plan.total_requirements}
|
||||
</td>
|
||||
<td className="py-3 px-4 text-right text-[var(--text-primary)]">
|
||||
€{formatters.currency(plan.total_estimated_cost)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
|
||||
{activeTab === 'performance' && (
|
||||
<>
|
||||
{/* Performance Tab */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<Card>
|
||||
<div className="p-6 text-center">
|
||||
<Target className="mx-auto h-8 w-8 text-[var(--color-success)] mb-3" />
|
||||
<div className="text-3xl font-bold text-[var(--text-primary)] mb-1">
|
||||
{formatters.percentage(dashboard?.stats?.avg_fulfillment_rate || 0)}
|
||||
</div>
|
||||
<div className="text-sm text-[var(--text-secondary)]">Tasa de Cumplimiento</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<div className="p-6 text-center">
|
||||
<Calendar className="mx-auto h-8 w-8 text-[var(--color-info)] mb-3" />
|
||||
<div className="text-3xl font-bold text-[var(--text-primary)] mb-1">
|
||||
{formatters.percentage(dashboard?.stats?.avg_on_time_delivery || 0)}
|
||||
</div>
|
||||
<div className="text-sm text-[var(--text-secondary)]">Entregas a Tiempo</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<div className="p-6 text-center">
|
||||
<Award className="mx-auto h-8 w-8 text-[var(--color-warning)] mb-3" />
|
||||
<div className="text-3xl font-bold text-[var(--text-primary)] mb-1">
|
||||
{dashboard?.stats?.avg_quality_score?.toFixed(1) || '0.0'}
|
||||
</div>
|
||||
<div className="text-sm text-[var(--text-secondary)]">Puntuación de Calidad</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Performance Trend Chart Placeholder */}
|
||||
<Card>
|
||||
<div className="p-6">
|
||||
<h3 className="text-lg font-medium text-[var(--text-primary)] mb-4">
|
||||
Tendencias de Rendimiento
|
||||
</h3>
|
||||
<div className="h-64 flex items-center justify-center text-[var(--text-tertiary)]">
|
||||
Gráfico de tendencias - Próximamente
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
|
||||
{activeTab === 'suppliers' && (
|
||||
<>
|
||||
{/* Suppliers Tab */}
|
||||
<Card>
|
||||
<div className="p-6">
|
||||
<h3 className="text-lg font-medium text-[var(--text-primary)] mb-4">
|
||||
Rendimiento de Proveedores
|
||||
</h3>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-[var(--border-primary)]">
|
||||
<th className="text-left py-3 px-4 text-[var(--text-secondary)] font-medium">Proveedor</th>
|
||||
<th className="text-right py-3 px-4 text-[var(--text-secondary)] font-medium">Órdenes</th>
|
||||
<th className="text-right py-3 px-4 text-[var(--text-secondary)] font-medium">Tasa Cumplimiento</th>
|
||||
<th className="text-right py-3 px-4 text-[var(--text-secondary)] font-medium">Entregas a Tiempo</th>
|
||||
<th className="text-right py-3 px-4 text-[var(--text-secondary)] font-medium">Calidad</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{dashboard?.supplier_performance?.map((supplier: any) => (
|
||||
<tr key={supplier.id} className="border-b border-[var(--border-primary)] hover:bg-[var(--bg-secondary)]">
|
||||
<td className="py-3 px-4 text-[var(--text-primary)]">{supplier.name}</td>
|
||||
<td className="py-3 px-4 text-right text-[var(--text-primary)]">{supplier.total_orders}</td>
|
||||
<td className="py-3 px-4 text-right text-[var(--text-primary)]">
|
||||
{formatters.percentage(supplier.fulfillment_rate)}
|
||||
</td>
|
||||
<td className="py-3 px-4 text-right text-[var(--text-primary)]">
|
||||
{formatters.percentage(supplier.on_time_rate)}
|
||||
</td>
|
||||
<td className="py-3 px-4 text-right text-[var(--text-primary)]">
|
||||
{supplier.quality_score?.toFixed(1) || 'N/A'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
|
||||
{activeTab === 'costs' && (
|
||||
<>
|
||||
{/* Costs Tab */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<Card>
|
||||
<div className="p-6">
|
||||
<h3 className="text-lg font-medium text-[var(--text-primary)] mb-4">
|
||||
Análisis de Costos
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-[var(--text-secondary)]">Costo Total Estimado</span>
|
||||
<span className="text-2xl font-bold text-[var(--text-primary)]">
|
||||
€{formatters.currency(dashboard?.cost_analysis?.total_estimated || 0)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-[var(--text-secondary)]">Costo Total Aprobado</span>
|
||||
<span className="text-2xl font-bold text-[var(--text-primary)]">
|
||||
€{formatters.currency(dashboard?.cost_analysis?.total_approved || 0)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-[var(--text-secondary)]">Variación Promedio</span>
|
||||
<span className={`text-2xl font-bold ${
|
||||
(dashboard?.cost_analysis?.avg_variance || 0) > 0
|
||||
? 'text-[var(--color-error)]'
|
||||
: 'text-[var(--color-success)]'
|
||||
}`}>
|
||||
{formatters.percentage(Math.abs(dashboard?.cost_analysis?.avg_variance || 0))}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<div className="p-6">
|
||||
<h3 className="text-lg font-medium text-[var(--text-primary)] mb-4">
|
||||
Distribución de Costos por Categoría
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
{dashboard?.cost_by_category?.map((category: any) => (
|
||||
<div key={category.name} className="flex items-center justify-between">
|
||||
<span className="text-[var(--text-secondary)]">{category.name}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-32 h-2 bg-[var(--bg-tertiary)] rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-[var(--color-primary)]"
|
||||
style={{ width: `${(category.amount / dashboard.cost_analysis.total_estimated) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-sm font-medium text-[var(--text-primary)] w-20 text-right">
|
||||
€{formatters.currency(category.amount)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{activeTab === 'quality' && (
|
||||
<>
|
||||
{/* Quality Tab */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<Card>
|
||||
<div className="p-6">
|
||||
<h3 className="text-lg font-medium text-[var(--text-primary)] mb-4">
|
||||
Métricas de Calidad
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-[var(--text-secondary)]">Puntuación Promedio</span>
|
||||
<span className="text-3xl font-bold text-[var(--text-primary)]">
|
||||
{dashboard?.quality_metrics?.avg_score?.toFixed(1) || '0.0'} / 10
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-[var(--text-secondary)]">Productos con Calidad Alta</span>
|
||||
<span className="text-2xl font-bold text-[var(--color-success)]">
|
||||
{dashboard?.quality_metrics?.high_quality_count || 0}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-[var(--text-secondary)]">Productos con Calidad Baja</span>
|
||||
<span className="text-2xl font-bold text-[var(--color-error)]">
|
||||
{dashboard?.quality_metrics?.low_quality_count || 0}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<div className="p-6">
|
||||
<h3 className="text-lg font-medium text-[var(--text-primary)] mb-4">
|
||||
Tendencia de Calidad
|
||||
</h3>
|
||||
<div className="h-48 flex items-center justify-center text-[var(--text-tertiary)]">
|
||||
Gráfico de tendencia de calidad - Próximamente
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Helper function for status colors
|
||||
function getStatusColor(status: string): string {
|
||||
const colors: Record<string, string> = {
|
||||
draft: 'bg-[var(--bg-tertiary)] text-[var(--text-secondary)]',
|
||||
pending_approval: 'bg-yellow-100 text-yellow-800',
|
||||
approved: 'bg-green-100 text-green-800',
|
||||
in_execution: 'bg-blue-100 text-blue-800',
|
||||
completed: 'bg-green-100 text-green-800',
|
||||
cancelled: 'bg-red-100 text-red-800'
|
||||
};
|
||||
return colors[status] || colors.draft;
|
||||
}
|
||||
|
||||
export default ProcurementAnalyticsPage;
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Plus, ShoppingCart, Truck, Euro, Calendar, Clock, CheckCircle, AlertCircle, Package, Eye, Loader, Edit, ArrowRight, X, Save, Building2, Play, Zap, User } from 'lucide-react';
|
||||
import { Button, StatsGrid, StatusCard, getStatusColor, EditViewModal, SearchAndFilter, type FilterConfig } from '../../../../components/ui';
|
||||
import { Button, Card, StatsGrid, StatusCard, getStatusColor, EditViewModal, SearchAndFilter, Input, type FilterConfig } from '../../../../components/ui';
|
||||
import { formatters } from '../../../../components/ui/Stats/StatsPresets';
|
||||
import { PageHeader } from '../../../../components/layout';
|
||||
import { CreatePurchaseOrderModal } from '../../../../components/domain/procurement/CreatePurchaseOrderModal';
|
||||
@@ -10,7 +10,13 @@ import {
|
||||
usePlanRequirements,
|
||||
useGenerateProcurementPlan,
|
||||
useUpdateProcurementPlanStatus,
|
||||
useTriggerDailyScheduler
|
||||
useTriggerDailyScheduler,
|
||||
useRecalculateProcurementPlan,
|
||||
useApproveProcurementPlan,
|
||||
useRejectProcurementPlan,
|
||||
useCreatePurchaseOrdersFromPlan,
|
||||
useLinkRequirementToPurchaseOrder,
|
||||
useUpdateRequirementDeliveryStatus
|
||||
} from '../../../../api';
|
||||
import { useTenantStore } from '../../../../stores/tenant.store';
|
||||
|
||||
@@ -38,6 +44,20 @@ const ProcurementPage: React.FC = () => {
|
||||
force_regenerate: false
|
||||
});
|
||||
|
||||
// New feature state
|
||||
const [showApprovalModal, setShowApprovalModal] = useState(false);
|
||||
const [approvalAction, setApprovalAction] = useState<'approve' | 'reject'>('approve');
|
||||
const [approvalNotes, setApprovalNotes] = useState('');
|
||||
const [planForApproval, setPlanForApproval] = useState<any>(null);
|
||||
const [showDeliveryUpdateModal, setShowDeliveryUpdateModal] = useState(false);
|
||||
const [requirementForDelivery, setRequirementForDelivery] = useState<any>(null);
|
||||
const [deliveryUpdateForm, setDeliveryUpdateForm] = useState({
|
||||
delivery_status: 'pending',
|
||||
received_quantity: 0,
|
||||
actual_delivery_date: '',
|
||||
quality_rating: 5
|
||||
});
|
||||
|
||||
|
||||
// Requirement details functionality
|
||||
const handleViewRequirementDetails = (requirement: any) => {
|
||||
@@ -82,6 +102,13 @@ const ProcurementPage: React.FC = () => {
|
||||
const updatePlanStatusMutation = useUpdateProcurementPlanStatus();
|
||||
const triggerSchedulerMutation = useTriggerDailyScheduler();
|
||||
|
||||
// New feature mutations
|
||||
const recalculatePlanMutation = useRecalculateProcurementPlan();
|
||||
const approvePlanMutation = useApproveProcurementPlan();
|
||||
const rejectPlanMutation = useRejectProcurementPlan();
|
||||
const createPOsMutation = useCreatePurchaseOrdersFromPlan();
|
||||
const updateDeliveryMutation = useUpdateRequirementDeliveryStatus();
|
||||
|
||||
// Helper functions for stage transitions and edit functionality
|
||||
const getNextStage = (currentStatus: string): string | null => {
|
||||
const stageFlow: { [key: string]: string } = {
|
||||
@@ -158,6 +185,96 @@ const ProcurementPage: React.FC = () => {
|
||||
setSelectedPlanForRequirements(null);
|
||||
};
|
||||
|
||||
// NEW FEATURE HANDLERS
|
||||
const handleRecalculatePlan = (plan: any) => {
|
||||
if (window.confirm('¿Recalcular el plan con el inventario actual? Esto puede cambiar las cantidades requeridas.')) {
|
||||
recalculatePlanMutation.mutate({ tenantId, planId: plan.id });
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenApprovalModal = (plan: any, action: 'approve' | 'reject') => {
|
||||
setPlanForApproval(plan);
|
||||
setApprovalAction(action);
|
||||
setApprovalNotes('');
|
||||
setShowApprovalModal(true);
|
||||
};
|
||||
|
||||
const handleConfirmApproval = () => {
|
||||
if (!planForApproval) return;
|
||||
|
||||
if (approvalAction === 'approve') {
|
||||
approvePlanMutation.mutate({
|
||||
tenantId,
|
||||
planId: planForApproval.id,
|
||||
approval_notes: approvalNotes || undefined
|
||||
}, {
|
||||
onSuccess: () => {
|
||||
setShowApprovalModal(false);
|
||||
setPlanForApproval(null);
|
||||
setApprovalNotes('');
|
||||
}
|
||||
});
|
||||
} else {
|
||||
rejectPlanMutation.mutate({
|
||||
tenantId,
|
||||
planId: planForApproval.id,
|
||||
rejection_notes: approvalNotes || undefined
|
||||
}, {
|
||||
onSuccess: () => {
|
||||
setShowApprovalModal(false);
|
||||
setPlanForApproval(null);
|
||||
setApprovalNotes('');
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreatePurchaseOrders = (plan: any) => {
|
||||
if (plan.status !== 'approved') {
|
||||
alert('El plan debe estar aprobado antes de crear órdenes de compra');
|
||||
return;
|
||||
}
|
||||
|
||||
if (window.confirm(`¿Crear órdenes de compra automáticamente para ${plan.total_requirements} requerimientos?`)) {
|
||||
createPOsMutation.mutate({
|
||||
tenantId,
|
||||
planId: plan.id,
|
||||
autoApprove: false
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenDeliveryUpdate = (requirement: any) => {
|
||||
setRequirementForDelivery(requirement);
|
||||
setDeliveryUpdateForm({
|
||||
delivery_status: requirement.delivery_status || 'pending',
|
||||
received_quantity: requirement.received_quantity || 0,
|
||||
actual_delivery_date: requirement.actual_delivery_date || '',
|
||||
quality_rating: requirement.quality_rating || 5
|
||||
});
|
||||
setShowDeliveryUpdateModal(true);
|
||||
};
|
||||
|
||||
const handleConfirmDeliveryUpdate = () => {
|
||||
if (!requirementForDelivery) return;
|
||||
|
||||
updateDeliveryMutation.mutate({
|
||||
tenantId,
|
||||
requirementId: requirementForDelivery.id,
|
||||
request: {
|
||||
delivery_status: deliveryUpdateForm.delivery_status,
|
||||
received_quantity: deliveryUpdateForm.received_quantity || undefined,
|
||||
actual_delivery_date: deliveryUpdateForm.actual_delivery_date || undefined,
|
||||
quality_rating: deliveryUpdateForm.quality_rating || undefined
|
||||
}
|
||||
}, {
|
||||
onSuccess: () => {
|
||||
setShowDeliveryUpdateModal(false);
|
||||
setRequirementForDelivery(null);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
if (!tenantId) {
|
||||
return (
|
||||
<div className="flex justify-center items-center h-64">
|
||||
@@ -413,8 +530,74 @@ const ProcurementPage: React.FC = () => {
|
||||
}
|
||||
);
|
||||
} else {
|
||||
// Primary action: Stage transition (most important)
|
||||
if (nextStageConfig) {
|
||||
// NEW FEATURES: Recalculate and Approval actions for draft/pending
|
||||
if (plan.status === 'draft') {
|
||||
const planAgeHours = (new Date().getTime() - new Date(plan.created_at).getTime()) / (1000 * 60 * 60);
|
||||
|
||||
actions.push({
|
||||
label: planAgeHours > 24 ? '⚠️ Recalcular' : 'Recalcular',
|
||||
icon: ArrowRight,
|
||||
variant: 'outline' as const,
|
||||
priority: 'primary' as const,
|
||||
onClick: () => handleRecalculatePlan(plan)
|
||||
});
|
||||
|
||||
actions.push({
|
||||
label: 'Aprobar',
|
||||
icon: CheckCircle,
|
||||
variant: 'primary' as const,
|
||||
priority: 'primary' as const,
|
||||
onClick: () => handleOpenApprovalModal(plan, 'approve')
|
||||
});
|
||||
|
||||
actions.push({
|
||||
label: 'Rechazar',
|
||||
icon: X,
|
||||
variant: 'outline' as const,
|
||||
priority: 'secondary' as const,
|
||||
destructive: true,
|
||||
onClick: () => handleOpenApprovalModal(plan, 'reject')
|
||||
});
|
||||
} else if (plan.status === 'pending_approval') {
|
||||
actions.push({
|
||||
label: 'Aprobar',
|
||||
icon: CheckCircle,
|
||||
variant: 'primary' as const,
|
||||
priority: 'primary' as const,
|
||||
onClick: () => handleOpenApprovalModal(plan, 'approve')
|
||||
});
|
||||
|
||||
actions.push({
|
||||
label: 'Rechazar',
|
||||
icon: X,
|
||||
variant: 'outline' as const,
|
||||
priority: 'secondary' as const,
|
||||
destructive: true,
|
||||
onClick: () => handleOpenApprovalModal(plan, 'reject')
|
||||
});
|
||||
}
|
||||
|
||||
// NEW FEATURE: Auto-create POs for approved plans
|
||||
if (plan.status === 'approved') {
|
||||
actions.push({
|
||||
label: 'Crear Órdenes de Compra',
|
||||
icon: ShoppingCart,
|
||||
variant: 'primary' as const,
|
||||
priority: 'primary' as const,
|
||||
onClick: () => handleCreatePurchaseOrders(plan)
|
||||
});
|
||||
|
||||
actions.push({
|
||||
label: 'Iniciar Ejecución',
|
||||
icon: Play,
|
||||
variant: 'outline' as const,
|
||||
priority: 'secondary' as const,
|
||||
onClick: () => handleStageTransition(plan.id, plan.status)
|
||||
});
|
||||
}
|
||||
|
||||
// Original stage transition for other statuses
|
||||
if (nextStageConfig && !['draft', 'pending_approval', 'approved'].includes(plan.status)) {
|
||||
actions.push({
|
||||
label: nextStageConfig.label,
|
||||
icon: nextStageConfig.icon,
|
||||
@@ -459,7 +642,7 @@ const ProcurementPage: React.FC = () => {
|
||||
// Tertiary action: Cancel (least prominent, destructive)
|
||||
if (!['completed', 'cancelled'].includes(plan.status)) {
|
||||
actions.push({
|
||||
label: 'Cancelar',
|
||||
label: 'Cancelar Plan',
|
||||
icon: X,
|
||||
variant: 'outline' as const,
|
||||
priority: 'tertiary' as const,
|
||||
@@ -1281,6 +1464,229 @@ const ProcurementPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* NEW FEATURE MODALS */}
|
||||
|
||||
{/* Approval/Rejection Modal */}
|
||||
{showApprovalModal && planForApproval && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||
<div className="bg-[var(--bg-primary)] rounded-lg shadow-xl w-full max-w-md mx-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-6 border-b border-[var(--border-primary)]">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className={`p-2 rounded-lg ${approvalAction === 'approve' ? 'bg-green-100' : 'bg-red-100'}`}>
|
||||
{approvalAction === 'approve' ? (
|
||||
<CheckCircle className={`w-5 h-5 text-green-600`} />
|
||||
) : (
|
||||
<X className={`w-5 h-5 text-red-600`} />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-[var(--text-primary)]">
|
||||
{approvalAction === 'approve' ? 'Aprobar Plan' : 'Rechazar Plan'}
|
||||
</h2>
|
||||
<p className="text-sm text-[var(--text-secondary)]">
|
||||
Plan {planForApproval.plan_number}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowApprovalModal(false)}
|
||||
className="p-2"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-6 space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">
|
||||
Notas {approvalAction === 'approve' ? '(Opcional)' : '(Requerido)'}
|
||||
</label>
|
||||
<textarea
|
||||
value={approvalNotes}
|
||||
onChange={(e) => setApprovalNotes(e.target.value)}
|
||||
placeholder={approvalAction === 'approve' ? 'Razón de aprobación...' : 'Razón de rechazo...'}
|
||||
className="w-full px-3 py-2 text-sm border border-[var(--border-primary)] rounded-lg
|
||||
bg-[var(--bg-primary)] text-[var(--text-primary)] placeholder-[var(--text-tertiary)]
|
||||
focus:ring-2 focus:ring-[var(--color-primary)]/20 focus:border-[var(--color-primary)]
|
||||
transition-colors duration-200 resize-vertical min-h-[100px]"
|
||||
rows={4}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="bg-[var(--bg-secondary)] p-4 rounded-lg">
|
||||
<h4 className="text-sm font-medium text-[var(--text-primary)] mb-2">Detalles del Plan</h4>
|
||||
<div className="space-y-1 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-[var(--text-secondary)]">Requerimientos:</span>
|
||||
<span className="text-[var(--text-primary)] font-medium">{planForApproval.total_requirements}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-[var(--text-secondary)]">Costo Estimado:</span>
|
||||
<span className="text-[var(--text-primary)] font-medium">€{planForApproval.total_estimated_cost?.toFixed(2)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-[var(--text-secondary)]">Proveedores:</span>
|
||||
<span className="text-[var(--text-primary)] font-medium">{planForApproval.primary_suppliers_count || 0}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex justify-end space-x-3 p-6 border-t border-[var(--border-primary)]">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowApprovalModal(false)}
|
||||
>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button
|
||||
variant={approvalAction === 'approve' ? 'primary' : 'outline'}
|
||||
onClick={handleConfirmApproval}
|
||||
disabled={approvePlanMutation.isPending || rejectPlanMutation.isPending}
|
||||
className={approvalAction === 'reject' ? 'bg-red-600 hover:bg-red-700 text-white' : ''}
|
||||
>
|
||||
{approvePlanMutation.isPending || rejectPlanMutation.isPending ? (
|
||||
<>
|
||||
<Loader className="w-4 h-4 mr-2 animate-spin" />
|
||||
Procesando...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{approvalAction === 'approve' ? <CheckCircle className="w-4 h-4 mr-2" /> : <X className="w-4 h-4 mr-2" />}
|
||||
{approvalAction === 'approve' ? 'Aprobar Plan' : 'Rechazar Plan'}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Delivery Status Update Modal */}
|
||||
{showDeliveryUpdateModal && requirementForDelivery && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||
<div className="bg-[var(--bg-primary)] rounded-lg shadow-xl w-full max-w-lg mx-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-6 border-b border-[var(--border-primary)]">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="p-2 rounded-lg bg-blue-100">
|
||||
<Truck className="w-5 h-5 text-blue-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-[var(--text-primary)]">
|
||||
Actualizar Estado de Entrega
|
||||
</h2>
|
||||
<p className="text-sm text-[var(--text-secondary)]">
|
||||
{requirementForDelivery.product_name}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowDeliveryUpdateModal(false)}
|
||||
className="p-2"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-6 space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">
|
||||
Estado de Entrega
|
||||
</label>
|
||||
<select
|
||||
value={deliveryUpdateForm.delivery_status}
|
||||
onChange={(e) => setDeliveryUpdateForm({ ...deliveryUpdateForm, delivery_status: e.target.value })}
|
||||
className="w-full px-3 py-2 text-sm border border-[var(--border-primary)] rounded-lg
|
||||
bg-[var(--bg-primary)] text-[var(--text-primary)]
|
||||
focus:ring-2 focus:ring-[var(--color-primary)]/20 focus:border-[var(--color-primary)]"
|
||||
>
|
||||
<option value="pending">Pendiente</option>
|
||||
<option value="in_transit">En Tránsito</option>
|
||||
<option value="delivered">Entregado</option>
|
||||
<option value="delayed">Retrasado</option>
|
||||
<option value="cancelled">Cancelado</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">
|
||||
Cantidad Recibida
|
||||
</label>
|
||||
<Input
|
||||
type="number"
|
||||
value={deliveryUpdateForm.received_quantity}
|
||||
onChange={(e) => setDeliveryUpdateForm({ ...deliveryUpdateForm, received_quantity: Number(e.target.value) })}
|
||||
min="0"
|
||||
step="0.01"
|
||||
/>
|
||||
<p className="text-xs text-[var(--text-tertiary)] mt-1">
|
||||
Ordenado: {requirementForDelivery.ordered_quantity || requirementForDelivery.net_requirement} {requirementForDelivery.unit_of_measure}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">
|
||||
Fecha de Entrega Real
|
||||
</label>
|
||||
<Input
|
||||
type="date"
|
||||
value={deliveryUpdateForm.actual_delivery_date}
|
||||
onChange={(e) => setDeliveryUpdateForm({ ...deliveryUpdateForm, actual_delivery_date: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">
|
||||
Calificación de Calidad (1-10)
|
||||
</label>
|
||||
<Input
|
||||
type="number"
|
||||
value={deliveryUpdateForm.quality_rating}
|
||||
onChange={(e) => setDeliveryUpdateForm({ ...deliveryUpdateForm, quality_rating: Number(e.target.value) })}
|
||||
min="1"
|
||||
max="10"
|
||||
step="0.1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex justify-end space-x-3 p-6 border-t border-[var(--border-primary)]">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowDeliveryUpdateModal(false)}
|
||||
>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handleConfirmDeliveryUpdate}
|
||||
disabled={updateDeliveryMutation.isPending}
|
||||
>
|
||||
{updateDeliveryMutation.isPending ? (
|
||||
<>
|
||||
<Loader className="w-4 h-4 mr-2 animate-spin" />
|
||||
Actualizando...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Save className="w-4 h-4 mr-2" />
|
||||
Actualizar Estado
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -22,6 +22,7 @@ const MaquinariaPage = React.lazy(() => import('../pages/app/operations/maquinar
|
||||
|
||||
// Analytics pages
|
||||
const ProductionAnalyticsPage = React.lazy(() => import('../pages/app/analytics/ProductionAnalyticsPage'));
|
||||
const ProcurementAnalyticsPage = React.lazy(() => import('../pages/app/analytics/ProcurementAnalyticsPage'));
|
||||
const ForecastingPage = React.lazy(() => import('../pages/app/analytics/forecasting/ForecastingPage'));
|
||||
const SalesAnalyticsPage = React.lazy(() => import('../pages/app/analytics/sales-analytics/SalesAnalyticsPage'));
|
||||
const AIInsightsPage = React.lazy(() => import('../pages/app/analytics/ai-insights/AIInsightsPage'));
|
||||
@@ -225,6 +226,16 @@ export const AppRouter: React.FC = () => {
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/app/analytics/procurement"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<AppShell>
|
||||
<ProcurementAnalyticsPage />
|
||||
</AppShell>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/app/analytics/forecasting"
|
||||
element={
|
||||
|
||||
@@ -89,7 +89,7 @@ export const ROUTES = {
|
||||
PROCUREMENT_ORDERS: '/procurement/orders',
|
||||
PROCUREMENT_SUPPLIERS: '/procurement/suppliers',
|
||||
PROCUREMENT_DELIVERIES: '/procurement/deliveries',
|
||||
PROCUREMENT_ANALYTICS: '/procurement/analytics',
|
||||
PROCUREMENT_ANALYTICS: '/app/analytics/procurement',
|
||||
|
||||
// Recipes
|
||||
RECIPES: '/app/database/recipes',
|
||||
@@ -289,6 +289,18 @@ export const routesConfig: RouteConfig[] = [
|
||||
showInNavigation: true,
|
||||
showInBreadcrumbs: true,
|
||||
},
|
||||
{
|
||||
path: '/app/analytics/procurement',
|
||||
name: 'ProcurementAnalytics',
|
||||
component: 'ProcurementAnalyticsPage',
|
||||
title: 'Análisis de Compras',
|
||||
icon: 'procurement',
|
||||
requiresAuth: true,
|
||||
requiredRoles: ROLE_COMBINATIONS.MANAGEMENT_ACCESS,
|
||||
requiredAnalyticsLevel: 'advanced',
|
||||
showInNavigation: true,
|
||||
showInBreadcrumbs: true,
|
||||
},
|
||||
{
|
||||
path: '/app/analytics/forecasting',
|
||||
name: 'Forecasting',
|
||||
|
||||
Reference in New Issue
Block a user