Add POI feature and imporve the overall backend implementation
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
Reference in New Issue
Block a user