Add POI feature and imporve the overall backend implementation

This commit is contained in:
Urtzi Alfaro
2025-11-12 15:34:10 +01:00
parent e8096cd979
commit 5783c7ed05
173 changed files with 16862 additions and 9078 deletions

View File

@@ -0,0 +1,499 @@
// ================================================================
// frontend/src/components/domain/procurement/DeliveryReceiptModal.tsx
// ================================================================
/**
* Delivery Receipt Modal
*
* Modal for recording delivery receipt with:
* - Item-by-item quantity verification
* - Batch/lot number entry
* - Expiration date entry
* - Quality inspection toggle
* - Rejection reasons for damaged/incorrect items
*/
import React, { useState, useMemo } from 'react';
import {
X,
Package,
Calendar,
Hash,
CheckCircle2,
AlertTriangle,
Truck,
ClipboardCheck,
} from 'lucide-react';
// Define delivery item type
interface DeliveryItemInput {
purchase_order_item_id: string;
inventory_product_id: string;
product_name: string;
ordered_quantity: number;
unit_of_measure: string;
delivered_quantity: number;
accepted_quantity: number;
rejected_quantity: number;
batch_lot_number?: string;
expiry_date?: string;
quality_issues?: string;
rejection_reason?: string;
}
interface DeliveryReceiptModalProps {
isOpen: boolean;
onClose: () => void;
purchaseOrder: {
id: string;
po_number: string;
supplier_id: string;
supplier_name?: string;
items: Array<{
id: string;
inventory_product_id: string;
product_name: string;
ordered_quantity: number;
unit_of_measure: string;
received_quantity: number;
}>;
};
onSubmit: (deliveryData: {
purchase_order_id: string;
supplier_id: string;
items: DeliveryItemInput[];
inspection_passed: boolean;
inspection_notes?: string;
notes?: string;
}) => Promise<void>;
loading?: boolean;
}
export function DeliveryReceiptModal({
isOpen,
onClose,
purchaseOrder,
onSubmit,
loading = false,
}: DeliveryReceiptModalProps) {
const [items, setItems] = useState<DeliveryItemInput[]>(() =>
purchaseOrder.items.map(item => ({
purchase_order_item_id: item.id,
inventory_product_id: item.inventory_product_id,
product_name: item.product_name,
ordered_quantity: item.ordered_quantity,
unit_of_measure: item.unit_of_measure,
delivered_quantity: item.ordered_quantity - item.received_quantity, // Remaining qty
accepted_quantity: item.ordered_quantity - item.received_quantity,
rejected_quantity: 0,
batch_lot_number: '',
expiry_date: '',
quality_issues: '',
rejection_reason: '',
}))
);
const [inspectionPassed, setInspectionPassed] = useState(true);
const [inspectionNotes, setInspectionNotes] = useState('');
const [generalNotes, setGeneralNotes] = useState('');
// Calculate summary statistics
const summary = useMemo(() => {
const totalOrdered = items.reduce((sum, item) => sum + item.ordered_quantity, 0);
const totalDelivered = items.reduce((sum, item) => sum + item.delivered_quantity, 0);
const totalAccepted = items.reduce((sum, item) => sum + item.accepted_quantity, 0);
const totalRejected = items.reduce((sum, item) => sum + item.rejected_quantity, 0);
const hasIssues = items.some(item => item.rejected_quantity > 0 || item.quality_issues);
return {
totalOrdered,
totalDelivered,
totalAccepted,
totalRejected,
hasIssues,
completionRate: totalOrdered > 0 ? (totalAccepted / totalOrdered) * 100 : 0,
};
}, [items]);
const updateItem = (index: number, field: keyof DeliveryItemInput, value: any) => {
setItems(prevItems => {
const newItems = [...prevItems];
newItems[index] = { ...newItems[index], [field]: value };
// Auto-calculate accepted quantity when delivered or rejected changes
if (field === 'delivered_quantity' || field === 'rejected_quantity') {
const delivered = field === 'delivered_quantity' ? value : newItems[index].delivered_quantity;
const rejected = field === 'rejected_quantity' ? value : newItems[index].rejected_quantity;
newItems[index].accepted_quantity = Math.max(0, delivered - rejected);
}
return newItems;
});
};
const handleSubmit = async () => {
// Validate that all items have required fields
const hasErrors = items.some(item =>
item.delivered_quantity < 0 ||
item.accepted_quantity < 0 ||
item.rejected_quantity < 0 ||
item.delivered_quantity < item.rejected_quantity
);
if (hasErrors) {
alert('Please fix validation errors before submitting');
return;
}
const deliveryData = {
purchase_order_id: purchaseOrder.id,
supplier_id: purchaseOrder.supplier_id,
items: items.filter(item => item.delivered_quantity > 0), // Only include delivered items
inspection_passed: inspectionPassed,
inspection_notes: inspectionNotes || undefined,
notes: generalNotes || undefined,
};
await onSubmit(deliveryData);
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div
className="rounded-lg shadow-xl max-w-4xl w-full max-h-[90vh] overflow-hidden flex flex-col"
style={{ backgroundColor: 'var(--bg-primary)' }}
>
{/* Header */}
<div
className="p-6 border-b flex items-center justify-between"
style={{ borderColor: 'var(--border-primary)' }}
>
<div className="flex items-center gap-3">
<Truck className="w-6 h-6" style={{ color: 'var(--color-info-600)' }} />
<div>
<h2 className="text-xl font-bold" style={{ color: 'var(--text-primary)' }}>
Record Delivery Receipt
</h2>
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
PO #{purchaseOrder.po_number} {purchaseOrder.supplier_name}
</p>
</div>
</div>
<button
onClick={onClose}
className="p-2 rounded-lg hover:bg-opacity-80 transition-colors"
style={{ backgroundColor: 'var(--bg-secondary)' }}
disabled={loading}
>
<X className="w-5 h-5" style={{ color: 'var(--text-secondary)' }} />
</button>
</div>
{/* Summary Stats */}
<div
className="p-4 border-b grid grid-cols-4 gap-4"
style={{
backgroundColor: 'var(--bg-secondary)',
borderColor: 'var(--border-primary)'
}}
>
<div>
<p className="text-xs font-medium" style={{ color: 'var(--text-secondary)' }}>
Ordered
</p>
<p className="text-lg font-bold" style={{ color: 'var(--text-primary)' }}>
{summary.totalOrdered.toFixed(1)}
</p>
</div>
<div>
<p className="text-xs font-medium" style={{ color: 'var(--text-secondary)' }}>
Delivered
</p>
<p className="text-lg font-bold" style={{ color: 'var(--color-info-600)' }}>
{summary.totalDelivered.toFixed(1)}
</p>
</div>
<div>
<p className="text-xs font-medium" style={{ color: 'var(--text-secondary)' }}>
Accepted
</p>
<p className="text-lg font-bold" style={{ color: 'var(--color-success-600)' }}>
{summary.totalAccepted.toFixed(1)}
</p>
</div>
<div>
<p className="text-xs font-medium" style={{ color: 'var(--text-secondary)' }}>
Rejected
</p>
<p className="text-lg font-bold" style={{ color: 'var(--color-error-600)' }}>
{summary.totalRejected.toFixed(1)}
</p>
</div>
</div>
{/* Items List */}
<div className="flex-1 overflow-y-auto p-6 space-y-4">
{items.map((item, index) => (
<div
key={item.purchase_order_item_id}
className="border rounded-lg p-4 space-y-3"
style={{
backgroundColor: 'var(--bg-primary)',
borderColor: item.rejected_quantity > 0
? 'var(--color-error-300)'
: 'var(--border-primary)',
}}
>
{/* Item Header */}
<div className="flex items-start justify-between">
<div className="flex items-start gap-2 flex-1">
<Package className="w-5 h-5 mt-0.5" style={{ color: 'var(--color-info-600)' }} />
<div>
<p className="font-semibold" style={{ color: 'var(--text-primary)' }}>
{item.product_name}
</p>
<p className="text-xs" style={{ color: 'var(--text-secondary)' }}>
Ordered: {item.ordered_quantity} {item.unit_of_measure}
</p>
</div>
</div>
</div>
{/* Quantity Inputs */}
<div className="grid grid-cols-3 gap-3">
<div>
<label className="block text-xs font-medium mb-1" style={{ color: 'var(--text-secondary)' }}>
Delivered Qty *
</label>
<input
type="number"
value={item.delivered_quantity}
onChange={(e) => updateItem(index, 'delivered_quantity', parseFloat(e.target.value) || 0)}
min="0"
step="0.01"
className="w-full px-3 py-2 rounded border text-sm"
style={{
backgroundColor: 'var(--bg-primary)',
borderColor: 'var(--border-primary)',
color: 'var(--text-primary)',
}}
disabled={loading}
/>
</div>
<div>
<label className="block text-xs font-medium mb-1" style={{ color: 'var(--text-secondary)' }}>
Rejected Qty
</label>
<input
type="number"
value={item.rejected_quantity}
onChange={(e) => updateItem(index, 'rejected_quantity', parseFloat(e.target.value) || 0)}
min="0"
step="0.01"
max={item.delivered_quantity}
className="w-full px-3 py-2 rounded border text-sm"
style={{
backgroundColor: 'var(--bg-primary)',
borderColor: item.rejected_quantity > 0 ? 'var(--color-error-300)' : 'var(--border-primary)',
color: 'var(--text-primary)',
}}
disabled={loading}
/>
</div>
<div>
<label className="block text-xs font-medium mb-1" style={{ color: 'var(--text-secondary)' }}>
Accepted Qty
</label>
<input
type="number"
value={item.accepted_quantity}
readOnly
className="w-full px-3 py-2 rounded border text-sm font-semibold"
style={{
backgroundColor: 'var(--bg-secondary)',
borderColor: 'var(--border-primary)',
color: 'var(--color-success-600)',
}}
/>
</div>
</div>
{/* Batch & Expiry */}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="flex items-center gap-1 text-xs font-medium mb-1" style={{ color: 'var(--text-secondary)' }}>
<Hash className="w-3 h-3" />
Batch/Lot Number
</label>
<input
type="text"
value={item.batch_lot_number}
onChange={(e) => updateItem(index, 'batch_lot_number', e.target.value)}
placeholder="Optional"
className="w-full px-3 py-2 rounded border text-sm"
style={{
backgroundColor: 'var(--bg-primary)',
borderColor: 'var(--border-primary)',
color: 'var(--text-primary)',
}}
disabled={loading}
/>
</div>
<div>
<label className="flex items-center gap-1 text-xs font-medium mb-1" style={{ color: 'var(--text-secondary)' }}>
<Calendar className="w-3 h-3" />
Expiration Date
</label>
<input
type="date"
value={item.expiry_date}
onChange={(e) => updateItem(index, 'expiry_date', e.target.value)}
className="w-full px-3 py-2 rounded border text-sm"
style={{
backgroundColor: 'var(--bg-primary)',
borderColor: 'var(--border-primary)',
color: 'var(--text-primary)',
}}
disabled={loading}
/>
</div>
</div>
{/* Quality Issues / Rejection Reason */}
{item.rejected_quantity > 0 && (
<div>
<label className="flex items-center gap-1 text-xs font-medium mb-1" style={{ color: 'var(--color-error-600)' }}>
<AlertTriangle className="w-3 h-3" />
Rejection Reason *
</label>
<textarea
value={item.rejection_reason}
onChange={(e) => updateItem(index, 'rejection_reason', e.target.value)}
placeholder="Why was this item rejected? (damaged, wrong product, quality issues, etc.)"
rows={2}
className="w-full px-3 py-2 rounded border text-sm"
style={{
backgroundColor: 'var(--bg-primary)',
borderColor: 'var(--color-error-300)',
color: 'var(--text-primary)',
}}
disabled={loading}
/>
</div>
)}
</div>
))}
</div>
{/* Quality Inspection */}
<div
className="p-4 border-t space-y-3"
style={{ borderColor: 'var(--border-primary)' }}
>
<div className="flex items-center gap-3">
<ClipboardCheck className="w-5 h-5" style={{ color: 'var(--color-info-600)' }} />
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={inspectionPassed}
onChange={(e) => setInspectionPassed(e.target.checked)}
className="w-4 h-4"
disabled={loading}
/>
<span className="font-medium" style={{ color: 'var(--text-primary)' }}>
Quality inspection passed
</span>
</label>
</div>
{!inspectionPassed && (
<textarea
value={inspectionNotes}
onChange={(e) => setInspectionNotes(e.target.value)}
placeholder="Describe quality inspection issues..."
rows={2}
className="w-full px-3 py-2 rounded border text-sm"
style={{
backgroundColor: 'var(--bg-primary)',
borderColor: 'var(--color-warning-300)',
color: 'var(--text-primary)',
}}
disabled={loading}
/>
)}
<textarea
value={generalNotes}
onChange={(e) => setGeneralNotes(e.target.value)}
placeholder="General delivery notes (optional)"
rows={2}
className="w-full px-3 py-2 rounded border text-sm"
style={{
backgroundColor: 'var(--bg-primary)',
borderColor: 'var(--border-primary)',
color: 'var(--text-primary)',
}}
disabled={loading}
/>
</div>
{/* Footer Actions */}
<div
className="p-6 border-t flex items-center justify-between"
style={{
backgroundColor: 'var(--bg-secondary)',
borderColor: 'var(--border-primary)'
}}
>
<div>
{summary.hasIssues && (
<p className="text-sm flex items-center gap-2" style={{ color: 'var(--color-warning-700)' }}>
<AlertTriangle className="w-4 h-4" />
This delivery has quality issues or rejections
</p>
)}
</div>
<div className="flex gap-3">
<button
onClick={onClose}
className="px-4 py-2 rounded-lg font-medium transition-colors"
style={{
backgroundColor: 'var(--bg-primary)',
color: 'var(--text-secondary)',
border: '1px solid var(--border-primary)',
}}
disabled={loading}
>
Cancel
</button>
<button
onClick={handleSubmit}
disabled={loading || summary.totalDelivered === 0}
className="px-4 py-2 rounded-lg font-medium transition-colors flex items-center gap-2"
style={{
backgroundColor: loading ? 'var(--color-info-300)' : 'var(--color-info-600)',
color: 'white',
opacity: loading || summary.totalDelivered === 0 ? 0.6 : 1,
cursor: loading || summary.totalDelivered === 0 ? 'not-allowed' : 'pointer',
}}
>
{loading ? (
<>Processing...</>
) : (
<>
<CheckCircle2 className="w-4 h-4" />
Record Delivery
</>
)}
</button>
</div>
</div>
</div>
</div>
);
}

View File

@@ -1,3 +1,4 @@
// Procurement Components - Components for procurement and purchase order management
export { default as CreatePurchaseOrderModal } from './CreatePurchaseOrderModal';
export { default as CreatePurchaseOrderModal } from './CreatePurchaseOrderModal';
export { DeliveryReceiptModal } from './DeliveryReceiptModal';