Add procurement management logic
This commit is contained in:
216
frontend/src/components/procurement/CriticalRequirements.tsx
Normal file
216
frontend/src/components/procurement/CriticalRequirements.tsx
Normal file
@@ -0,0 +1,216 @@
|
||||
// ================================================================
|
||||
// frontend/src/components/procurement/CriticalRequirements.tsx
|
||||
// ================================================================
|
||||
/**
|
||||
* Critical Requirements Component
|
||||
* Displays urgent procurement requirements that need immediate attention
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import type { ProcurementRequirement } from '@/api/types/procurement';
|
||||
import { Priority, RequirementStatus } from '@/api/types/procurement';
|
||||
|
||||
export interface CriticalRequirementsProps {
|
||||
requirements: ProcurementRequirement[];
|
||||
onViewDetails?: (requirementId: string) => void;
|
||||
onUpdateStatus?: (requirementId: string, status: string) => void;
|
||||
}
|
||||
|
||||
export const CriticalRequirements: React.FC<CriticalRequirementsProps> = ({
|
||||
requirements,
|
||||
onViewDetails,
|
||||
onUpdateStatus,
|
||||
}) => {
|
||||
const formatCurrency = (amount: number | undefined) => {
|
||||
if (!amount) return 'N/A';
|
||||
return new Intl.NumberFormat('es-ES', {
|
||||
style: 'currency',
|
||||
currency: 'EUR'
|
||||
}).format(amount);
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
const date = new Date(dateString);
|
||||
const today = new Date();
|
||||
const diffTime = date.getTime() - today.getTime();
|
||||
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (diffDays < 0) {
|
||||
return `Overdue by ${Math.abs(diffDays)} days`;
|
||||
} else if (diffDays === 0) {
|
||||
return 'Due today';
|
||||
} else if (diffDays === 1) {
|
||||
return 'Due tomorrow';
|
||||
} else {
|
||||
return `Due in ${diffDays} days`;
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
const colors = {
|
||||
[RequirementStatus.PENDING]: 'bg-yellow-100 text-yellow-800',
|
||||
[RequirementStatus.APPROVED]: 'bg-blue-100 text-blue-800',
|
||||
[RequirementStatus.ORDERED]: 'bg-purple-100 text-purple-800',
|
||||
[RequirementStatus.PARTIALLY_RECEIVED]: 'bg-orange-100 text-orange-800',
|
||||
[RequirementStatus.RECEIVED]: 'bg-green-100 text-green-800',
|
||||
[RequirementStatus.CANCELLED]: 'bg-red-100 text-red-800',
|
||||
};
|
||||
return colors[status as keyof typeof colors] || 'bg-gray-100 text-gray-800';
|
||||
};
|
||||
|
||||
const getDueDateColor = (dateString: string) => {
|
||||
const date = new Date(dateString);
|
||||
const today = new Date();
|
||||
const diffTime = date.getTime() - today.getTime();
|
||||
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (diffDays < 0) return 'text-red-600 font-medium'; // Overdue
|
||||
if (diffDays <= 1) return 'text-orange-600 font-medium'; // Due soon
|
||||
return 'text-gray-600';
|
||||
};
|
||||
|
||||
const getStockLevelColor = (current: number, needed: number) => {
|
||||
const ratio = current / needed;
|
||||
if (ratio <= 0.1) return 'text-red-600 font-medium'; // Critical
|
||||
if (ratio <= 0.3) return 'text-orange-600 font-medium'; // Low
|
||||
return 'text-gray-600';
|
||||
};
|
||||
|
||||
if (requirements.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
<p>No critical requirements at this time</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{requirements.map((requirement) => (
|
||||
<div
|
||||
key={requirement.id}
|
||||
className="border border-red-200 rounded-lg p-4 bg-red-50 hover:bg-red-100 transition-colors"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center space-x-2 mb-2">
|
||||
<h4 className="font-medium text-gray-900">
|
||||
{requirement.product_name}
|
||||
</h4>
|
||||
<span className={`px-2 py-1 rounded-full text-xs font-medium ${getStatusColor(requirement.status)}`}>
|
||||
{requirement.status.replace('_', ' ').toUpperCase()}
|
||||
</span>
|
||||
<span className="px-2 py-1 rounded-full text-xs font-medium bg-red-100 text-red-700">
|
||||
CRITICAL
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="text-gray-500">Required:</span>
|
||||
<div className="font-medium">
|
||||
{requirement.net_requirement} {requirement.unit_of_measure}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span className="text-gray-500">Current Stock:</span>
|
||||
<div className={getStockLevelColor(requirement.current_stock_level, requirement.net_requirement)}>
|
||||
{requirement.current_stock_level} {requirement.unit_of_measure}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span className="text-gray-500">Due Date:</span>
|
||||
<div className={getDueDateColor(requirement.required_by_date)}>
|
||||
{formatDate(requirement.required_by_date)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span className="text-gray-500">Est. Cost:</span>
|
||||
<div className="font-medium">
|
||||
{formatCurrency(requirement.estimated_total_cost)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{requirement.supplier_name && (
|
||||
<div className="mt-2 text-sm">
|
||||
<span className="text-gray-500">Supplier:</span>
|
||||
<span className="ml-1 font-medium">{requirement.supplier_name}</span>
|
||||
{requirement.supplier_lead_time_days && (
|
||||
<span className="ml-2 text-gray-500">
|
||||
({requirement.supplier_lead_time_days} days lead time)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{requirement.special_requirements && (
|
||||
<div className="mt-2 p-2 bg-yellow-50 rounded border border-yellow-200">
|
||||
<span className="text-xs text-yellow-700 font-medium">Special Requirements:</span>
|
||||
<p className="text-xs text-yellow-600 mt-1">
|
||||
{requirement.special_requirements}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col space-y-2 ml-4">
|
||||
{requirement.status === RequirementStatus.PENDING && (
|
||||
<button
|
||||
onClick={() => onUpdateStatus?.(requirement.id, RequirementStatus.APPROVED)}
|
||||
className="px-3 py-1 bg-blue-600 text-white text-xs rounded hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
Approve
|
||||
</button>
|
||||
)}
|
||||
|
||||
{requirement.status === RequirementStatus.APPROVED && (
|
||||
<button
|
||||
onClick={() => onUpdateStatus?.(requirement.id, RequirementStatus.ORDERED)}
|
||||
className="px-3 py-1 bg-purple-600 text-white text-xs rounded hover:bg-purple-700 transition-colors"
|
||||
>
|
||||
Order Now
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => onViewDetails?.(requirement.id)}
|
||||
className="px-3 py-1 bg-gray-200 text-gray-700 text-xs rounded hover:bg-gray-300 transition-colors"
|
||||
>
|
||||
Details
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress indicator for ordered items */}
|
||||
{requirement.status === RequirementStatus.ORDERED && requirement.ordered_quantity > 0 && (
|
||||
<div className="mt-3 pt-3 border-t border-red-200">
|
||||
<div className="flex justify-between text-xs text-gray-600 mb-1">
|
||||
<span>Order Progress</span>
|
||||
<span>
|
||||
{requirement.received_quantity} / {requirement.ordered_quantity} {requirement.unit_of_measure}
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 rounded-full h-2">
|
||||
<div
|
||||
className="bg-blue-600 h-2 rounded-full transition-all"
|
||||
style={{
|
||||
width: `${Math.min(100, (requirement.received_quantity / requirement.ordered_quantity) * 100)}%`
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{requirement.expected_delivery_date && (
|
||||
<div className="text-xs text-gray-500 mt-1">
|
||||
Expected: {formatDate(requirement.expected_delivery_date)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
202
frontend/src/components/procurement/GeneratePlanModal.tsx
Normal file
202
frontend/src/components/procurement/GeneratePlanModal.tsx
Normal file
@@ -0,0 +1,202 @@
|
||||
// ================================================================
|
||||
// frontend/src/components/procurement/GeneratePlanModal.tsx
|
||||
// ================================================================
|
||||
/**
|
||||
* Generate Plan Modal Component
|
||||
* Modal for configuring and generating new procurement plans
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import type { GeneratePlanRequest } from '@/api/types/procurement';
|
||||
|
||||
export interface GeneratePlanModalProps {
|
||||
onGenerate: (request: GeneratePlanRequest) => void;
|
||||
onClose: () => void;
|
||||
isGenerating: boolean;
|
||||
error?: Error | null;
|
||||
}
|
||||
|
||||
export const GeneratePlanModal: React.FC<GeneratePlanModalProps> = ({
|
||||
onGenerate,
|
||||
onClose,
|
||||
isGenerating,
|
||||
error,
|
||||
}) => {
|
||||
const [formData, setFormData] = useState<GeneratePlanRequest>({
|
||||
plan_date: new Date().toISOString().split('T')[0], // Today
|
||||
force_regenerate: false,
|
||||
planning_horizon_days: 14,
|
||||
include_safety_stock: true,
|
||||
safety_stock_percentage: 20,
|
||||
});
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
onGenerate(formData);
|
||||
};
|
||||
|
||||
const handleInputChange = (field: keyof GeneratePlanRequest, value: any) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[field]: value,
|
||||
}));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-lg p-6 w-full max-w-md mx-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold text-gray-900">
|
||||
Generate Procurement Plan
|
||||
</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
disabled={isGenerating}
|
||||
className="text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{/* Plan Date */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Plan Date
|
||||
</label>
|
||||
<Input
|
||||
type="date"
|
||||
value={formData.plan_date || ''}
|
||||
onChange={(e) => handleInputChange('plan_date', e.target.value)}
|
||||
disabled={isGenerating}
|
||||
className="w-full"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Date for which to generate the procurement plan
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Planning Horizon */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Planning Horizon (days)
|
||||
</label>
|
||||
<Input
|
||||
type="number"
|
||||
min="1"
|
||||
max="30"
|
||||
value={formData.planning_horizon_days || 14}
|
||||
onChange={(e) => handleInputChange('planning_horizon_days', parseInt(e.target.value))}
|
||||
disabled={isGenerating}
|
||||
className="w-full"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Number of days to plan ahead (1-30)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Safety Stock */}
|
||||
<div>
|
||||
<div className="flex items-center mb-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="include_safety_stock"
|
||||
checked={formData.include_safety_stock || false}
|
||||
onChange={(e) => handleInputChange('include_safety_stock', e.target.checked)}
|
||||
disabled={isGenerating}
|
||||
className="h-4 w-4 text-blue-600 rounded border-gray-300"
|
||||
/>
|
||||
<label htmlFor="include_safety_stock" className="ml-2 text-sm font-medium text-gray-700">
|
||||
Include Safety Stock
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{formData.include_safety_stock && (
|
||||
<div className="ml-6">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Safety Stock Percentage
|
||||
</label>
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
max="100"
|
||||
step="5"
|
||||
value={formData.safety_stock_percentage || 20}
|
||||
onChange={(e) => handleInputChange('safety_stock_percentage', parseFloat(e.target.value))}
|
||||
disabled={isGenerating}
|
||||
className="w-full"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Additional buffer stock as percentage of demand (0-100%)
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Force Regenerate */}
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="force_regenerate"
|
||||
checked={formData.force_regenerate || false}
|
||||
onChange={(e) => handleInputChange('force_regenerate', e.target.checked)}
|
||||
disabled={isGenerating}
|
||||
className="h-4 w-4 text-blue-600 rounded border-gray-300"
|
||||
/>
|
||||
<label htmlFor="force_regenerate" className="ml-2 text-sm font-medium text-gray-700">
|
||||
Force Regenerate
|
||||
</label>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">
|
||||
Regenerate plan even if one already exists for this date
|
||||
</p>
|
||||
|
||||
{/* Error Display */}
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 rounded p-3">
|
||||
<p className="text-red-600 text-sm">
|
||||
{error.message || 'Failed to generate plan'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex items-center justify-end space-x-3 pt-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onClose}
|
||||
disabled={isGenerating}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isGenerating}
|
||||
>
|
||||
{isGenerating ? 'Generating...' : 'Generate Plan'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* Generation Progress */}
|
||||
{isGenerating && (
|
||||
<div className="mt-4 p-3 bg-blue-50 rounded">
|
||||
<div className="flex items-center">
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-blue-600 mr-2"></div>
|
||||
<span className="text-sm text-blue-700">
|
||||
Generating procurement plan...
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2 text-xs text-blue-600">
|
||||
This may take a few moments while we analyze inventory and forecast demand.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
268
frontend/src/components/procurement/ProcurementDashboard.tsx
Normal file
268
frontend/src/components/procurement/ProcurementDashboard.tsx
Normal file
@@ -0,0 +1,268 @@
|
||||
// ================================================================
|
||||
// frontend/src/components/procurement/ProcurementDashboard.tsx
|
||||
// ================================================================
|
||||
/**
|
||||
* Procurement Dashboard Component
|
||||
* Main dashboard for procurement planning functionality
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { LoadingSpinner } from '@/components/ui/LoadingSpinner';
|
||||
import {
|
||||
useProcurementPlanDashboard,
|
||||
useProcurementPlanActions
|
||||
} from '@/api/hooks/useProcurement';
|
||||
import type {
|
||||
ProcurementPlan,
|
||||
ProcurementRequirement,
|
||||
GeneratePlanRequest
|
||||
} from '@/api/types/procurement';
|
||||
import { ProcurementPlanCard } from './ProcurementPlanCard';
|
||||
import { ProcurementSummary } from './ProcurementSummary';
|
||||
import { CriticalRequirements } from './CriticalRequirements';
|
||||
import { GeneratePlanModal } from './GeneratePlanModal';
|
||||
|
||||
export interface ProcurementDashboardProps {
|
||||
showFilters?: boolean;
|
||||
refreshInterval?: number;
|
||||
onPlanGenerated?: (plan: ProcurementPlan) => void;
|
||||
}
|
||||
|
||||
export const ProcurementDashboard: React.FC<ProcurementDashboardProps> = ({
|
||||
showFilters = true,
|
||||
refreshInterval = 5 * 60 * 1000, // 5 minutes
|
||||
onPlanGenerated,
|
||||
}) => {
|
||||
const [showGenerateModal, setShowGenerateModal] = useState(false);
|
||||
|
||||
const {
|
||||
currentPlan,
|
||||
dashboard,
|
||||
criticalRequirements,
|
||||
health,
|
||||
isLoading,
|
||||
error,
|
||||
refetchAll
|
||||
} = useProcurementPlanDashboard();
|
||||
|
||||
const {
|
||||
generatePlan,
|
||||
updateStatus,
|
||||
triggerScheduler,
|
||||
isGenerating,
|
||||
generateError
|
||||
} = useProcurementPlanActions();
|
||||
|
||||
const handleGeneratePlan = (request: GeneratePlanRequest) => {
|
||||
generatePlan(request, {
|
||||
onSuccess: (response) => {
|
||||
if (response.success && response.plan) {
|
||||
onPlanGenerated?.(response.plan);
|
||||
setShowGenerateModal(false);
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleStatusUpdate = (planId: string, status: string) => {
|
||||
updateStatus({ planId, status });
|
||||
};
|
||||
|
||||
const handleTriggerScheduler = () => {
|
||||
triggerScheduler();
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<LoadingSpinner size="lg" />
|
||||
<span className="ml-2">Loading procurement dashboard...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
|
||||
<h3 className="text-red-800 font-medium">Error Loading Dashboard</h3>
|
||||
<p className="text-red-600 mt-1">
|
||||
{error.message || 'Unable to load procurement dashboard data'}
|
||||
</p>
|
||||
<Button
|
||||
onClick={refetchAll}
|
||||
className="mt-2"
|
||||
variant="outline"
|
||||
>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const dashboardData = dashboard.data;
|
||||
const currentPlanData = currentPlan.data;
|
||||
const criticalReqs = criticalRequirements.data || [];
|
||||
const serviceHealth = health.data;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header with Actions */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">
|
||||
Procurement Planning
|
||||
</h1>
|
||||
<p className="text-gray-600 mt-1">
|
||||
Manage daily procurement plans and requirements
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-3">
|
||||
{serviceHealth && !serviceHealth.procurement_enabled && (
|
||||
<div className="bg-yellow-100 border border-yellow-300 rounded px-3 py-1">
|
||||
<span className="text-yellow-800 text-sm">
|
||||
Service Disabled
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
onClick={handleTriggerScheduler}
|
||||
variant="outline"
|
||||
disabled={isGenerating}
|
||||
>
|
||||
Run Scheduler
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={() => setShowGenerateModal(true)}
|
||||
disabled={isGenerating}
|
||||
>
|
||||
{isGenerating ? 'Generating...' : 'Generate Plan'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Current Plan Section */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<div className="lg:col-span-2">
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold">Today's Procurement Plan</h2>
|
||||
<Button
|
||||
onClick={refetchAll}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{currentPlanData ? (
|
||||
<ProcurementPlanCard
|
||||
plan={currentPlanData}
|
||||
onUpdateStatus={handleStatusUpdate}
|
||||
showActions={true}
|
||||
/>
|
||||
) : (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
<p>No procurement plan for today</p>
|
||||
<Button
|
||||
onClick={() => setShowGenerateModal(true)}
|
||||
className="mt-2"
|
||||
size="sm"
|
||||
>
|
||||
Generate Plan
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Summary Statistics */}
|
||||
<div>
|
||||
<Card className="p-6">
|
||||
<h2 className="text-lg font-semibold mb-4">Summary</h2>
|
||||
{dashboardData?.summary ? (
|
||||
<ProcurementSummary summary={dashboardData.summary} />
|
||||
) : (
|
||||
<div className="text-gray-500">No summary data available</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Critical Requirements */}
|
||||
{criticalReqs.length > 0 && (
|
||||
<Card className="p-6">
|
||||
<h2 className="text-lg font-semibold mb-4 text-red-600">
|
||||
Critical Requirements ({criticalReqs.length})
|
||||
</h2>
|
||||
<CriticalRequirements requirements={criticalReqs} />
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Additional Dashboard Widgets */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{/* Upcoming Deliveries */}
|
||||
{dashboardData?.upcoming_deliveries?.length > 0 && (
|
||||
<Card className="p-6">
|
||||
<h3 className="text-md font-semibold mb-3">Upcoming Deliveries</h3>
|
||||
<div className="space-y-2">
|
||||
{dashboardData.upcoming_deliveries.slice(0, 5).map((delivery, index) => (
|
||||
<div key={index} className="flex justify-between text-sm">
|
||||
<span>{delivery.product_name}</span>
|
||||
<span className="text-gray-500">{delivery.expected_date}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Low Stock Alerts */}
|
||||
{dashboardData?.low_stock_alerts?.length > 0 && (
|
||||
<Card className="p-6">
|
||||
<h3 className="text-md font-semibold mb-3 text-orange-600">
|
||||
Low Stock Alerts
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{dashboardData.low_stock_alerts.slice(0, 5).map((alert, index) => (
|
||||
<div key={index} className="flex justify-between text-sm">
|
||||
<span>{alert.product_name}</span>
|
||||
<span className="text-orange-600">{alert.current_stock}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Performance Metrics */}
|
||||
{dashboardData?.performance_metrics && (
|
||||
<Card className="p-6">
|
||||
<h3 className="text-md font-semibold mb-3">Performance</h3>
|
||||
<div className="space-y-3">
|
||||
{Object.entries(dashboardData.performance_metrics).map(([key, value]) => (
|
||||
<div key={key} className="flex justify-between text-sm">
|
||||
<span className="capitalize">{key.replace('_', ' ')}</span>
|
||||
<span className="font-medium">{value as string}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Generate Plan Modal */}
|
||||
{showGenerateModal && (
|
||||
<GeneratePlanModal
|
||||
onGenerate={handleGeneratePlan}
|
||||
onClose={() => setShowGenerateModal(false)}
|
||||
isGenerating={isGenerating}
|
||||
error={generateError}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
235
frontend/src/components/procurement/ProcurementPlanCard.tsx
Normal file
235
frontend/src/components/procurement/ProcurementPlanCard.tsx
Normal file
@@ -0,0 +1,235 @@
|
||||
// ================================================================
|
||||
// frontend/src/components/procurement/ProcurementPlanCard.tsx
|
||||
// ================================================================
|
||||
/**
|
||||
* Procurement Plan Card Component
|
||||
* Displays a procurement plan with key information and actions
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import type { ProcurementPlan } from '@/api/types/procurement';
|
||||
import { PlanStatus, Priority } from '@/api/types/procurement';
|
||||
|
||||
export interface ProcurementPlanCardProps {
|
||||
plan: ProcurementPlan;
|
||||
onViewDetails?: (planId: string) => void;
|
||||
onUpdateStatus?: (planId: string, status: string) => void;
|
||||
showActions?: boolean;
|
||||
}
|
||||
|
||||
export const ProcurementPlanCard: React.FC<ProcurementPlanCardProps> = ({
|
||||
plan,
|
||||
onViewDetails,
|
||||
onUpdateStatus,
|
||||
showActions = false,
|
||||
}) => {
|
||||
const getStatusColor = (status: string) => {
|
||||
const colors = {
|
||||
[PlanStatus.DRAFT]: 'bg-gray-100 text-gray-800',
|
||||
[PlanStatus.PENDING_APPROVAL]: 'bg-yellow-100 text-yellow-800',
|
||||
[PlanStatus.APPROVED]: 'bg-blue-100 text-blue-800',
|
||||
[PlanStatus.IN_EXECUTION]: 'bg-green-100 text-green-800',
|
||||
[PlanStatus.COMPLETED]: 'bg-green-100 text-green-800',
|
||||
[PlanStatus.CANCELLED]: 'bg-red-100 text-red-800',
|
||||
};
|
||||
return colors[status as keyof typeof colors] || 'bg-gray-100 text-gray-800';
|
||||
};
|
||||
|
||||
const getPriorityColor = (priority: string) => {
|
||||
const colors = {
|
||||
[Priority.CRITICAL]: 'text-red-600',
|
||||
[Priority.HIGH]: 'text-orange-600',
|
||||
[Priority.NORMAL]: 'text-blue-600',
|
||||
[Priority.LOW]: 'text-gray-600',
|
||||
};
|
||||
return colors[priority as keyof typeof colors] || 'text-gray-600';
|
||||
};
|
||||
|
||||
const getRiskColor = (risk: string) => {
|
||||
const colors = {
|
||||
'critical': 'text-red-600',
|
||||
'high': 'text-orange-600',
|
||||
'medium': 'text-yellow-600',
|
||||
'low': 'text-green-600',
|
||||
};
|
||||
return colors[risk as keyof typeof colors] || 'text-gray-600';
|
||||
};
|
||||
|
||||
const formatCurrency = (amount: number) => {
|
||||
return new Intl.NumberFormat('es-ES', {
|
||||
style: 'currency',
|
||||
currency: 'EUR'
|
||||
}).format(amount);
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleDateString('es-ES', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
year: 'numeric'
|
||||
});
|
||||
};
|
||||
|
||||
const nextStatusOptions = () => {
|
||||
const options = {
|
||||
[PlanStatus.DRAFT]: [PlanStatus.PENDING_APPROVAL, PlanStatus.CANCELLED],
|
||||
[PlanStatus.PENDING_APPROVAL]: [PlanStatus.APPROVED, PlanStatus.CANCELLED],
|
||||
[PlanStatus.APPROVED]: [PlanStatus.IN_EXECUTION, PlanStatus.CANCELLED],
|
||||
[PlanStatus.IN_EXECUTION]: [PlanStatus.COMPLETED, PlanStatus.CANCELLED],
|
||||
[PlanStatus.COMPLETED]: [],
|
||||
[PlanStatus.CANCELLED]: [],
|
||||
};
|
||||
return options[plan.status as keyof typeof options] || [];
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="border border-gray-200">
|
||||
<div className="p-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div>
|
||||
<div className="flex items-center space-x-3">
|
||||
<h3 className="text-lg font-semibold text-gray-900">
|
||||
{plan.plan_number}
|
||||
</h3>
|
||||
<span className={`px-2 py-1 rounded-full text-xs font-medium ${getStatusColor(plan.status)}`}>
|
||||
{plan.status.replace('_', ' ').toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 mt-1">
|
||||
Plan Date: {formatDate(plan.plan_date)} |
|
||||
Period: {formatDate(plan.plan_period_start)} - {formatDate(plan.plan_period_end)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="text-right">
|
||||
<div className={`text-sm font-medium ${getPriorityColor(plan.priority)}`}>
|
||||
{plan.priority.toUpperCase()} Priority
|
||||
</div>
|
||||
<div className={`text-xs ${getRiskColor(plan.supply_risk_level)}`}>
|
||||
{plan.supply_risk_level.toUpperCase()} Risk
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Key Metrics */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-4">
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-blue-600">
|
||||
{plan.total_requirements}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">Requirements</div>
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-green-600">
|
||||
{formatCurrency(plan.total_estimated_cost)}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">Est. Cost</div>
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-purple-600">
|
||||
{plan.primary_suppliers_count}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">Suppliers</div>
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-orange-600">
|
||||
{plan.safety_stock_buffer}%
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">Safety Buffer</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Requirements Summary */}
|
||||
{plan.requirements && plan.requirements.length > 0 && (
|
||||
<div className="mb-4">
|
||||
<h4 className="text-sm font-medium text-gray-700 mb-2">
|
||||
Top Requirements ({plan.requirements.length} total)
|
||||
</h4>
|
||||
<div className="space-y-1">
|
||||
{plan.requirements.slice(0, 3).map((req) => (
|
||||
<div key={req.id} className="flex justify-between items-center text-sm">
|
||||
<span className="truncate">{req.product_name}</span>
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="text-gray-500">
|
||||
{req.net_requirement} {req.unit_of_measure}
|
||||
</span>
|
||||
<span className={`px-1 py-0.5 rounded text-xs ${
|
||||
req.priority === Priority.CRITICAL ? 'bg-red-100 text-red-700' :
|
||||
req.priority === Priority.HIGH ? 'bg-orange-100 text-orange-700' :
|
||||
'bg-gray-100 text-gray-700'
|
||||
}`}>
|
||||
{req.priority}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{plan.requirements.length > 3 && (
|
||||
<div className="text-xs text-gray-500 text-center">
|
||||
+{plan.requirements.length - 3} more requirements
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Performance Metrics */}
|
||||
{(plan.fulfillment_rate || plan.on_time_delivery_rate) && (
|
||||
<div className="flex items-center space-x-4 text-sm text-gray-600 mb-4">
|
||||
{plan.fulfillment_rate && (
|
||||
<span>Fulfillment: {plan.fulfillment_rate}%</span>
|
||||
)}
|
||||
{plan.on_time_delivery_rate && (
|
||||
<span>On-time: {plan.on_time_delivery_rate}%</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
{showActions && (
|
||||
<div className="flex items-center justify-between pt-4 border-t border-gray-100">
|
||||
<div className="flex space-x-2">
|
||||
{nextStatusOptions().map((status) => (
|
||||
<Button
|
||||
key={status}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => onUpdateStatus?.(plan.id, status)}
|
||||
>
|
||||
{status === PlanStatus.PENDING_APPROVAL && 'Submit for Approval'}
|
||||
{status === PlanStatus.APPROVED && 'Approve'}
|
||||
{status === PlanStatus.IN_EXECUTION && 'Start Execution'}
|
||||
{status === PlanStatus.COMPLETED && 'Mark Complete'}
|
||||
{status === PlanStatus.CANCELLED && 'Cancel'}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => onViewDetails?.(plan.id)}
|
||||
>
|
||||
View Details →
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Special Requirements */}
|
||||
{plan.special_requirements && (
|
||||
<div className="mt-4 p-3 bg-blue-50 rounded-lg">
|
||||
<h5 className="text-sm font-medium text-blue-800 mb-1">
|
||||
Special Requirements
|
||||
</h5>
|
||||
<p className="text-sm text-blue-700">{plan.special_requirements}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
171
frontend/src/components/procurement/ProcurementSummary.tsx
Normal file
171
frontend/src/components/procurement/ProcurementSummary.tsx
Normal file
@@ -0,0 +1,171 @@
|
||||
// ================================================================
|
||||
// frontend/src/components/procurement/ProcurementSummary.tsx
|
||||
// ================================================================
|
||||
/**
|
||||
* Procurement Summary Component
|
||||
* Displays key metrics and summary information
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import type { ProcurementSummary } from '@/api/types/procurement';
|
||||
|
||||
export interface ProcurementSummaryProps {
|
||||
summary: ProcurementSummary;
|
||||
}
|
||||
|
||||
export const ProcurementSummary: React.FC<ProcurementSummaryProps> = ({
|
||||
summary,
|
||||
}) => {
|
||||
const formatCurrency = (amount: number) => {
|
||||
return new Intl.NumberFormat('es-ES', {
|
||||
style: 'currency',
|
||||
currency: 'EUR'
|
||||
}).format(amount);
|
||||
};
|
||||
|
||||
const formatPercentage = (value: number | undefined) => {
|
||||
if (value === undefined || value === null) return 'N/A';
|
||||
return `${value.toFixed(1)}%`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Plan Metrics */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-700 mb-3">Plan Overview</h4>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="text-center">
|
||||
<div className="text-xl font-bold text-blue-600">
|
||||
{summary.total_plans}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">Total Plans</div>
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<div className="text-xl font-bold text-green-600">
|
||||
{summary.active_plans}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">Active Plans</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Requirements Metrics */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-700 mb-3">Requirements</h4>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-sm text-gray-600">Total</span>
|
||||
<span className="text-sm font-medium">{summary.total_requirements}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between">
|
||||
<span className="text-sm text-gray-600">Pending</span>
|
||||
<span className="text-sm font-medium text-yellow-600">
|
||||
{summary.pending_requirements}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between">
|
||||
<span className="text-sm text-gray-600">Critical</span>
|
||||
<span className="text-sm font-medium text-red-600">
|
||||
{summary.critical_requirements}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Cost Metrics */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-700 mb-3">Financial</h4>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-sm text-gray-600">Estimated</span>
|
||||
<span className="text-sm font-medium text-blue-600">
|
||||
{formatCurrency(summary.total_estimated_cost)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between">
|
||||
<span className="text-sm text-gray-600">Approved</span>
|
||||
<span className="text-sm font-medium text-green-600">
|
||||
{formatCurrency(summary.total_approved_cost)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between">
|
||||
<span className="text-sm text-gray-600">Variance</span>
|
||||
<span className={`text-sm font-medium ${
|
||||
summary.cost_variance >= 0 ? 'text-green-600' : 'text-red-600'
|
||||
}`}>
|
||||
{summary.cost_variance >= 0 ? '+' : ''}
|
||||
{formatCurrency(summary.cost_variance)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Performance Metrics */}
|
||||
{(summary.average_fulfillment_rate || summary.average_on_time_delivery) && (
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-700 mb-3">Performance</h4>
|
||||
<div className="space-y-2">
|
||||
{summary.average_fulfillment_rate && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-sm text-gray-600">Fulfillment Rate</span>
|
||||
<span className="text-sm font-medium">
|
||||
{formatPercentage(summary.average_fulfillment_rate)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{summary.average_on_time_delivery && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-sm text-gray-600">On-Time Delivery</span>
|
||||
<span className="text-sm font-medium">
|
||||
{formatPercentage(summary.average_on_time_delivery)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Top Suppliers */}
|
||||
{summary.top_suppliers.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-700 mb-3">Top Suppliers</h4>
|
||||
<div className="space-y-1">
|
||||
{summary.top_suppliers.slice(0, 3).map((supplier, index) => (
|
||||
<div key={index} className="flex justify-between text-sm">
|
||||
<span className="truncate">{supplier.name}</span>
|
||||
<span className="text-gray-500">
|
||||
{supplier.count || 0} orders
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Critical Items */}
|
||||
{summary.critical_items.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-700 mb-3 text-red-600">
|
||||
Critical Items
|
||||
</h4>
|
||||
<div className="space-y-1">
|
||||
{summary.critical_items.slice(0, 3).map((item, index) => (
|
||||
<div key={index} className="flex justify-between text-sm">
|
||||
<span className="truncate">{item.name}</span>
|
||||
<span className="text-red-500">
|
||||
{item.stock || 0} left
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
18
frontend/src/components/procurement/index.ts
Normal file
18
frontend/src/components/procurement/index.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
// ================================================================
|
||||
// frontend/src/components/procurement/index.ts
|
||||
// ================================================================
|
||||
/**
|
||||
* Procurement Components Export
|
||||
* Main export point for procurement planning components
|
||||
*/
|
||||
|
||||
export { ProcurementDashboard } from './ProcurementDashboard';
|
||||
export { ProcurementPlanCard } from './ProcurementPlanCard';
|
||||
export { ProcurementSummary } from './ProcurementSummary';
|
||||
export { CriticalRequirements } from './CriticalRequirements';
|
||||
export { GeneratePlanModal } from './GeneratePlanModal';
|
||||
|
||||
export type {
|
||||
ProcurementDashboardProps,
|
||||
ProcurementPlanCardProps
|
||||
} from './ProcurementDashboard';
|
||||
Reference in New Issue
Block a user