Files
bakery-ia/frontend/src/pages/app/operations/procurement/ProcurementPage.tsx

594 lines
23 KiB
TypeScript
Raw Normal View History

2025-08-28 10:41:04 +02:00
import React, { useState } from 'react';
import { Plus, Search, Download, ShoppingCart, Truck, DollarSign, Calendar, Clock, CheckCircle, AlertCircle, Package, Eye, Loader } from 'lucide-react';
import { Button, Input, Card, StatsGrid, StatusCard, getStatusColor, StatusModal } from '../../../../components/ui';
2025-08-30 19:11:15 +02:00
import { formatters } from '../../../../components/ui/Stats/StatsPresets';
2025-08-28 10:41:04 +02:00
import { PageHeader } from '../../../../components/layout';
import {
useProcurementDashboard,
useProcurementPlans,
useCurrentProcurementPlan,
useCriticalRequirements,
useGenerateProcurementPlan,
useUpdateProcurementPlanStatus,
useTriggerDailyScheduler
} from '../../../../api';
import { useTenantStore } from '../../../../stores/tenant.store';
2025-08-28 10:41:04 +02:00
const ProcurementPage: React.FC = () => {
const [activeTab, setActiveTab] = useState('plans');
2025-08-28 10:41:04 +02:00
const [searchTerm, setSearchTerm] = useState('');
const [showForm, setShowForm] = useState(false);
const [modalMode, setModalMode] = useState<'view' | 'edit'>('view');
const [selectedPlan, setSelectedPlan] = useState<any>(null);
const { currentTenant } = useTenantStore();
const tenantId = currentTenant?.id || '';
2025-08-28 10:41:04 +02:00
// Real API data hooks
const { data: dashboardData, isLoading: isDashboardLoading } = useProcurementDashboard(tenantId);
const { data: procurementPlans, isLoading: isPlansLoading } = useProcurementPlans({
tenant_id: tenantId,
limit: 50,
offset: 0
});
const { data: currentPlan, isLoading: isCurrentPlanLoading } = useCurrentProcurementPlan(tenantId);
const { data: criticalRequirements, isLoading: isCriticalLoading } = useCriticalRequirements(tenantId);
const generatePlanMutation = useGenerateProcurementPlan();
const updatePlanStatusMutation = useUpdateProcurementPlanStatus();
const triggerSchedulerMutation = useTriggerDailyScheduler();
2025-08-28 10:41:04 +02:00
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>
);
}
2025-08-28 10:41:04 +02:00
const getPlanStatusConfig = (status: string) => {
2025-08-28 10:41:04 +02:00
const statusConfig = {
draft: { text: 'Borrador', icon: Clock },
pending_approval: { text: 'Pendiente Aprobación', icon: Clock },
approved: { text: 'Aprobado', icon: CheckCircle },
in_execution: { text: 'En Ejecución', icon: Truck },
completed: { text: 'Completado', icon: CheckCircle },
cancelled: { text: 'Cancelado', icon: AlertCircle },
2025-08-28 10:41:04 +02:00
};
const config = statusConfig[status as keyof typeof statusConfig];
2025-08-30 19:11:15 +02:00
const Icon = config?.icon;
2025-08-28 10:41:04 +02:00
return {
color: getStatusColor(status === 'in_execution' ? 'inTransit' : status === 'pending_approval' ? 'pending' : status),
text: config?.text || status,
icon: Icon,
isCritical: status === 'cancelled',
isHighlight: status === 'pending_approval'
};
2025-08-28 10:41:04 +02:00
};
const filteredPlans = procurementPlans?.plans?.filter(plan => {
const matchesSearch = plan.plan_number.toLowerCase().includes(searchTerm.toLowerCase()) ||
plan.status.toLowerCase().includes(searchTerm.toLowerCase()) ||
(plan.special_requirements && plan.special_requirements.toLowerCase().includes(searchTerm.toLowerCase()));
2025-08-30 19:11:15 +02:00
return matchesSearch;
}) || [];
2025-08-30 19:11:15 +02:00
const stats = {
totalPlans: dashboardData?.summary?.total_plans || 0,
activePlans: dashboardData?.summary?.active_plans || 0,
pendingRequirements: dashboardData?.summary?.pending_requirements || 0,
criticalRequirements: dashboardData?.summary?.critical_requirements || 0,
totalEstimatedCost: dashboardData?.summary?.total_estimated_cost || 0,
totalApprovedCost: dashboardData?.summary?.total_approved_cost || 0,
2025-08-30 19:11:15 +02:00
};
const procurementStats = [
2025-08-28 18:07:16 +02:00
{
title: 'Planes Totales',
value: stats.totalPlans,
2025-08-30 19:11:15 +02:00
variant: 'default' as const,
icon: Package,
2025-08-28 18:07:16 +02:00
},
{
title: 'Planes Activos',
value: stats.activePlans,
variant: 'success' as const,
icon: CheckCircle,
},
{
title: 'Requerimientos Pendientes',
value: stats.pendingRequirements,
2025-08-30 19:11:15 +02:00
variant: 'warning' as const,
icon: Clock,
2025-08-28 18:07:16 +02:00
},
{
title: 'Críticos',
value: stats.criticalRequirements,
variant: 'warning' as const,
icon: AlertCircle,
2025-08-28 18:07:16 +02:00
},
{
title: 'Costo Estimado',
value: formatters.currency(stats.totalEstimatedCost),
variant: 'info' as const,
icon: DollarSign,
2025-08-28 18:07:16 +02:00
},
{
title: 'Costo Aprobado',
value: formatters.currency(stats.totalApprovedCost),
2025-08-30 19:11:15 +02:00
variant: 'success' as const,
icon: DollarSign,
2025-08-28 18:07:16 +02:00
},
];
2025-08-28 10:41:04 +02:00
return (
2025-08-30 19:21:15 +02:00
<div className="space-y-6">
2025-08-28 10:41:04 +02:00
<PageHeader
title="Planificación de Compras"
description="Administra planes de compras, requerimientos y análisis de procurement"
2025-08-30 19:11:15 +02:00
actions={[
{
id: "export",
label: "Exportar",
variant: "outline" as const,
icon: Download,
onClick: () => console.log('Export procurement data')
2025-08-30 19:11:15 +02:00
},
{
id: "generate",
label: "Generar Plan",
2025-08-30 19:11:15 +02:00
variant: "primary" as const,
icon: Plus,
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)
2025-08-30 19:11:15 +02:00
}
]}
2025-08-28 10:41:04 +02:00
/>
2025-08-30 19:11:15 +02:00
{/* Stats Grid */}
{isDashboardLoading ? (
<div className="flex justify-center items-center h-32">
<Loader className="w-8 h-8 animate-spin text-[var(--color-primary)]" />
</div>
) : (
<StatsGrid
stats={procurementStats}
columns={3}
/>
)}
2025-08-28 10:41:04 +02:00
{/* Tabs Navigation */}
<div className="border-b border-[var(--border-primary)]">
<nav className="-mb-px flex space-x-8">
<button
onClick={() => setActiveTab('plans')}
2025-08-28 10:41:04 +02:00
className={`py-2 px-1 border-b-2 font-medium text-sm ${
activeTab === 'plans'
2025-08-28 10:41:04 +02:00
? 'border-orange-500 text-[var(--color-primary)]'
: 'border-transparent text-[var(--text-tertiary)] hover:text-[var(--text-secondary)] hover:border-[var(--border-secondary)]'
}`}
>
Planes de Compra
2025-08-28 10:41:04 +02:00
</button>
<button
onClick={() => setActiveTab('requirements')}
2025-08-28 10:41:04 +02:00
className={`py-2 px-1 border-b-2 font-medium text-sm ${
activeTab === 'requirements'
2025-08-28 10:41:04 +02:00
? 'border-orange-500 text-[var(--color-primary)]'
: 'border-transparent text-[var(--text-tertiary)] hover:text-[var(--text-secondary)] hover:border-[var(--border-secondary)]'
}`}
>
Requerimientos Críticos
2025-08-28 10:41:04 +02:00
</button>
<button
onClick={() => setActiveTab('analytics')}
className={`py-2 px-1 border-b-2 font-medium text-sm ${
activeTab === 'analytics'
? 'border-orange-500 text-[var(--color-primary)]'
: 'border-transparent text-[var(--text-tertiary)] hover:text-[var(--text-secondary)] hover:border-[var(--border-secondary)]'
}`}
>
Análisis
</button>
</nav>
</div>
{activeTab === 'plans' && (
2025-08-30 19:11:15 +02:00
<Card className="p-4">
<div className="flex flex-col sm:flex-row gap-4">
<div className="flex-1">
2025-08-28 10:41:04 +02:00
<Input
placeholder="Buscar planes por número, estado o notas..."
2025-08-28 10:41:04 +02:00
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
2025-08-30 19:11:15 +02:00
className="w-full"
2025-08-28 10:41:04 +02:00
/>
</div>
2025-08-30 19:11:15 +02:00
<Button variant="outline" onClick={() => console.log('Export filtered')}>
2025-08-28 10:41:04 +02:00
<Download className="w-4 h-4 mr-2" />
Exportar
</Button>
</div>
2025-08-30 19:11:15 +02:00
</Card>
)}
2025-08-28 10:41:04 +02:00
{/* Procurement Plans Grid */}
{activeTab === 'plans' && (
2025-08-30 19:11:15 +02:00
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{isPlansLoading ? (
<div className="col-span-full flex justify-center items-center h-32">
<Loader className="w-8 h-8 animate-spin text-[var(--color-primary)]" />
</div>
) : (
filteredPlans.map((plan) => {
const statusConfig = getPlanStatusConfig(plan.status);
return (
<StatusCard
key={plan.id}
id={plan.plan_number}
statusIndicator={statusConfig}
title={`Plan ${plan.plan_number}`}
subtitle={new Date(plan.plan_date).toLocaleDateString('es-ES')}
primaryValue={formatters.currency(plan.total_estimated_cost)}
primaryValueLabel={`${plan.total_requirements} requerimientos`}
secondaryInfo={{
label: 'Período',
value: `${new Date(plan.plan_period_start).toLocaleDateString('es-ES')} - ${new Date(plan.plan_period_end).toLocaleDateString('es-ES')}`
}}
metadata={[
`${plan.planning_horizon_days} días de horizonte`,
`Estrategia: ${plan.procurement_strategy}`,
...(plan.special_requirements ? [`"${plan.special_requirements}"`] : [])
]}
actions={[
{
label: 'Ver',
icon: Eye,
variant: 'outline',
onClick: () => {
setSelectedPlan(plan);
setModalMode('view');
setShowForm(true);
}
},
...(plan.status === 'pending_approval' ? [{
label: 'Aprobar',
icon: CheckCircle,
variant: 'outline' as const,
onClick: () => {
updatePlanStatusMutation.mutate({
tenant_id: tenantId,
plan_id: plan.id,
status: 'approved'
});
}
}] : [])
]}
/>
);
})
)}
2025-08-30 19:11:15 +02:00
</div>
)}
{/* Empty State for Procurement Plans */}
{activeTab === 'plans' && !isPlansLoading && filteredPlans.length === 0 && (
2025-08-30 19:11:15 +02:00
<div className="text-center py-12">
<Package className="mx-auto h-12 w-12 text-[var(--text-tertiary)] mb-4" />
2025-08-30 19:11:15 +02:00
<h3 className="text-lg font-medium text-[var(--text-primary)] mb-2">
No se encontraron planes de compra
2025-08-30 19:11:15 +02:00
</h3>
<p className="text-[var(--text-secondary)] mb-4">
Intenta ajustar la búsqueda o generar un nuevo plan de compra
2025-08-30 19:11:15 +02:00
</p>
<Button
onClick={() => generatePlanMutation.mutate({
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
2025-08-30 19:11:15 +02:00
</Button>
</div>
2025-08-28 10:41:04 +02:00
)}
{activeTab === 'requirements' && (
<div className="space-y-4">
{isCriticalLoading ? (
<div className="flex justify-center items-center h-32">
<Loader className="w-8 h-8 animate-spin text-[var(--color-primary)]" />
</div>
) : criticalRequirements && criticalRequirements.length > 0 ? (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{criticalRequirements.map((requirement) => (
<StatusCard
key={requirement.id}
id={requirement.requirement_number}
statusIndicator={{
color: getStatusColor('danger'),
text: 'Crítico',
icon: AlertCircle,
isCritical: true
}}
title={requirement.product_name}
subtitle={requirement.requirement_number}
primaryValue={`${requirement.required_quantity} ${requirement.unit_of_measure}`}
primaryValueLabel="Cantidad requerida"
secondaryInfo={{
label: 'Fecha límite',
value: new Date(requirement.required_by_date).toLocaleDateString('es-ES')
}}
metadata={[
`Stock actual: ${requirement.current_stock_level} ${requirement.unit_of_measure}`,
`Proveedor: ${requirement.supplier_name || 'No asignado'}`,
`Costo estimado: ${formatters.currency(requirement.estimated_total_cost || 0)}`
]}
actions={[
{
label: 'Ver Detalles',
icon: Eye,
variant: 'outline',
onClick: () => console.log('View requirement details')
}
]}
/>
))}
</div>
) : (
<div className="text-center py-12">
<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">
No hay requerimientos críticos
</h3>
<p className="text-[var(--text-secondary)]">
Todos los requerimientos están bajo control
</p>
</div>
)}
2025-08-28 10:41:04 +02:00
</div>
)}
{activeTab === 'analytics' && (
<div className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<Card className="p-6">
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-4">Costos de Procurement</h3>
<div className="space-y-4">
<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>
2025-08-28 10:41:04 +02:00
</div>
</Card>
<Card className="p-6">
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-4">Alertas Críticas</h3>
2025-08-28 10:41:04 +02:00
<div className="space-y-3">
{dashboardData?.low_stock_alerts?.slice(0, 5).map((alert: any, index: number) => (
<div key={index} className="flex items-center justify-between p-3 bg-red-50 rounded-lg">
<div className="flex items-center">
<AlertCircle className="w-4 h-4 text-red-500 mr-2" />
<span className="text-sm text-[var(--text-primary)]">{alert.product_name || `Alerta ${index + 1}`}</span>
2025-08-28 10:41:04 +02:00
</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>
)}
2025-08-28 10:41:04 +02:00
</div>
</Card>
</div>
<Card className="p-6">
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-4">Resumen de Performance</h3>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<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>
2025-08-28 10:41:04 +02:00
</div>
</Card>
</div>
)}
{/* Procurement Plan Modal */}
{showForm && selectedPlan && (
<StatusModal
isOpen={showForm}
onClose={() => {
setShowForm(false);
setSelectedPlan(null);
setModalMode('view');
}}
mode={modalMode}
onModeChange={setModalMode}
title={`Plan de Compra ${selectedPlan.plan_number}`}
subtitle={new Date(selectedPlan.plan_date).toLocaleDateString('es-ES')}
statusIndicator={getPlanStatusConfig(selectedPlan.status)}
size="lg"
sections={[
{
title: 'Información del Plan',
icon: Package,
fields: [
{
label: 'Número de Plan',
value: selectedPlan.plan_number,
highlight: true
},
{
label: 'Tipo de Plan',
value: selectedPlan.plan_type
},
{
label: 'Estrategia',
value: selectedPlan.procurement_strategy
},
{
label: 'Prioridad',
value: selectedPlan.priority,
type: 'status'
}
]
},
{
title: 'Fechas y Períodos',
icon: Calendar,
fields: [
{
label: 'Fecha del Plan',
value: selectedPlan.plan_date,
type: 'date'
},
{
label: 'Período de Inicio',
value: selectedPlan.plan_period_start,
type: 'date'
},
{
label: 'Período de Fin',
value: selectedPlan.plan_period_end,
type: 'date',
highlight: true
},
{
label: 'Horizonte de Planificación',
value: `${selectedPlan.planning_horizon_days} días`
}
]
},
{
title: 'Información Financiera',
icon: DollarSign,
fields: [
{
label: 'Costo Estimado Total',
value: selectedPlan.total_estimated_cost,
type: 'currency',
highlight: true
},
{
label: 'Costo Aprobado Total',
value: selectedPlan.total_approved_cost,
type: 'currency'
},
{
label: 'Varianza de Costo',
value: selectedPlan.cost_variance,
type: 'currency'
}
]
},
{
title: 'Estadísticas',
icon: ShoppingCart,
fields: [
{
label: 'Total de Requerimientos',
value: `${selectedPlan.total_requirements} requerimientos`
},
{
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`
}
]
},
...(selectedPlan.special_requirements ? [{
title: 'Requerimientos Especiales',
fields: [
{
label: 'Observaciones',
value: selectedPlan.special_requirements,
span: 2 as const,
editable: true,
placeholder: 'Añadir requerimientos especiales para el plan...'
}
]
}] : [])
]}
onEdit={() => {
console.log('Editing procurement plan:', selectedPlan.id);
}}
/>
)}
2025-08-28 10:41:04 +02:00
</div>
);
};
export default ProcurementPage;