// ================================================================ // frontend/src/components/domain/procurement/UnifiedPurchaseOrderModal.tsx // ================================================================ /** * Unified Purchase Order Modal * A comprehensive view/edit modal for Purchase Orders that combines the best * UI/UX approaches from both dashboard and procurement pages */ import React, { useState, useEffect, useMemo } from 'react'; import { Package, Building2, Calendar, Euro, FileText, CheckCircle, Edit, AlertCircle, X } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import { usePurchaseOrder, useUpdatePurchaseOrder } from '../../../api/hooks/purchase-orders'; import { useUserById } from '../../../api/hooks/user'; import { EditViewModal, EditViewModalSection } from '../../ui/EditViewModal/EditViewModal'; import { Button } from '../../ui/Button'; import type { PurchaseOrderItem } from '../../../api/services/purchase_orders'; interface UnifiedPurchaseOrderModalProps { poId: string; tenantId: string; isOpen: boolean; onClose: () => void; onApprove?: (poId: string) => void; onReject?: (poId: string, reason: string) => void; initialMode?: 'view' | 'edit'; showApprovalActions?: boolean; // Whether to show approve/reject actions } export const UnifiedPurchaseOrderModal: React.FC = ({ poId, tenantId, isOpen, onClose, onApprove, onReject, initialMode = 'view', showApprovalActions = false }) => { const { t, i18n } = useTranslation(['purchase_orders', 'common']); const { data: po, isLoading, refetch } = usePurchaseOrder(tenantId, poId); const [mode, setMode] = useState<'view' | 'edit'>(initialMode); const [showApprovalModal, setShowApprovalModal] = useState(false); const [approvalAction, setApprovalAction] = useState<'approve' | 'reject'>('approve'); const [approvalNotes, setApprovalNotes] = useState(''); const updatePurchaseOrderMutation = useUpdatePurchaseOrder(); // Form state for edit mode const [formData, setFormData] = useState>({}); // Initialize form data when entering edit mode useEffect(() => { if (mode === 'edit' && po) { setFormData({ priority: po.priority, required_delivery_date: po.required_delivery_date || '', notes: po.notes || '', items: (po.items || []).map((item: PurchaseOrderItem) => ({ id: item.id, inventory_product_id: item.inventory_product_id, product_code: item.product_code || '', product_name: item.product_name || '', ordered_quantity: item.ordered_quantity, unit_of_measure: item.unit_of_measure, unit_price: parseFloat(item.unit_price), })), }); } }, [mode, po]); // Field change handler for edit mode const handleFieldChange = (sectionIndex: number, fieldIndex: number, value: any) => { // Map section/field indices to form field names // Section 0 = supplier_info (not editable) // Section 1 = order_details: [priority, required_delivery_date, notes] // Section 2 = products: [items] if (sectionIndex === 1) { // Order details section const fieldNames = ['priority', 'required_delivery_date', 'notes']; const fieldName = fieldNames[fieldIndex]; if (fieldName) { setFormData(prev => ({ ...prev, [fieldName]: value })); } } else if (sectionIndex === 2 && fieldIndex === 0) { // Products section - items field setFormData(prev => ({ ...prev, items: value })); } }; // Component to display user name with data fetching const UserName: React.FC<{ userId: string | undefined | null }> = ({ userId }) => { const { data: user, isLoading: userLoading } = useUserById(userId, { retry: 1, staleTime: 10 * 60 * 1000, }); if (!userId) { return <>{t('common:not_available')}; } if (userId === '00000000-0000-0000-0000-000000000001' || userId === '00000000-0000-0000-0000-000000000000') { return <>{t('common:system')}; } if (userLoading) { return <>{t('common:loading')}; } if (!user) { return <>{t('common:unknown_user')}; } return <>{user.full_name || user.email || t('common:user')}; }; // Component to display PO items const PurchaseOrderItemsTable: React.FC<{ items: PurchaseOrderItem[] }> = ({ items }) => { if (!items || items.length === 0) { return (

{t('no_items')}

); } const totalAmount = items.reduce((sum, item) => { const price = parseFloat(item.unit_price) || 0; const quantity = item.ordered_quantity || 0; return sum + (price * quantity); }, 0); return (
{items.map((item: PurchaseOrderItem, index: number) => { const unitPrice = parseFloat(item.unit_price) || 0; const quantity = item.ordered_quantity || 0; const itemTotal = unitPrice * quantity; const productName = item.product_name || `${t('product')} ${index + 1}`; return (

{productName}

{item.product_code && (

{t('sku')}: {item.product_code}

)}

€{itemTotal.toFixed(2)}

{t('quantity')}

{quantity} {item.unit_of_measure}

{t('unit_price')}

€{unitPrice.toFixed(2)}

{item.quality_requirements && (

{t('quality_requirements')}

{item.quality_requirements}

)} {item.item_notes && (

{t('common:notes')}

{item.item_notes}

)}
); })}
{t('total')} €{totalAmount.toFixed(2)}
); }; // Priority and unit options for edit mode const priorityOptions = [ { value: 'urgent', label: t('priority_urgent') }, { value: 'high', label: t('priority_high') }, { value: 'normal', label: t('priority_normal') }, { value: 'low', label: t('priority_low') } ]; const unitOptions = [ { value: 'kg', label: t('unit_kg') }, { value: 'g', label: t('unit_g') }, { value: 'l', label: t('unit_l') }, { value: 'ml', label: t('unit_ml') }, { value: 'units', label: t('unit_units') }, { value: 'boxes', label: t('unit_boxes') }, { value: 'bags', label: t('unit_bags') } ]; // Build sections for EditViewModal const buildViewSections = (): EditViewModalSection[] => { if (!po) return []; const formatCurrency = (value: any) => { const num = Number(value); return isNaN(num) ? '0.00' : num.toFixed(2); }; const sections: EditViewModalSection[] = [ { title: t('general_information'), icon: FileText, fields: [ { label: t('po_number'), value: po.po_number, type: 'text' as const }, { label: t('status_label'), value: t(`status.${po.status}`), type: 'status' as const }, { label: t('priority'), value: t(`priority_${po.priority}` as any) || po.priority, type: 'text' as const }, { label: t('created'), value: new Date(po.created_at).toLocaleDateString(i18n.language, { year: 'numeric', month: 'long', day: 'numeric', hour: '2-digit', minute: '2-digit' }), type: 'text' as const } ] }, { title: t('supplier_info'), icon: Building2, fields: [ { label: t('supplier_name'), value: po.supplier?.name || t('common:unknown'), type: 'text' as const }, ...(po.supplier?.supplier_code ? [{ label: t('supplier_code'), value: po.supplier.supplier_code, type: 'text' as const }] : []), ...(po.supplier?.email ? [{ label: t('email'), value: po.supplier.email, type: 'text' as const }] : []), ...(po.supplier?.phone ? [{ label: t('phone'), value: po.supplier.phone, type: 'text' as const }] : []) ] }, { title: t('financial_summary'), icon: Euro, fields: [ ...(po.subtotal !== undefined ? [{ label: t('subtotal'), value: `€${formatCurrency(po.subtotal)}`, type: 'text' as const }] : []), ...(po.tax_amount !== undefined ? [{ label: t('tax'), value: `€${formatCurrency(po.tax_amount)}`, type: 'text' as const }] : []), ...(po.discount_amount !== undefined ? [{ label: t('discount'), value: `€${formatCurrency(po.discount_amount)}`, type: 'text' as const }] : []), { label: t('total_amount'), value: `€${formatCurrency(po.total_amount)}`, type: 'text' as const, highlight: true } ] }, { title: t('items'), icon: Package, fields: [ { label: '', value: , type: 'component' as const, span: 2 } ] }, { title: t('delivery'), icon: Calendar, fields: [ ...(po.required_delivery_date ? [{ label: t('required_delivery_date'), value: new Date(po.required_delivery_date).toLocaleDateString(i18n.language, { year: 'numeric', month: 'short', day: 'numeric' }), type: 'text' as const }] : []), ...(po.estimated_delivery_date ? [{ label: t('expected_delivery'), value: new Date(po.estimated_delivery_date).toLocaleDateString(i18n.language, { year: 'numeric', month: 'short', day: 'numeric' }), type: 'text' as const }] : []), ...(po.actual_delivery_date ? [{ label: t('actual_delivery'), value: new Date(po.actual_delivery_date).toLocaleDateString(i18n.language, { year: 'numeric', month: 'short', day: 'numeric' }), type: 'text' as const }] : []) ] } ]; // Add approval section if approval data exists if (po.approved_by || po.approved_at || po.approval_notes) { sections.push({ title: t('approval'), icon: CheckCircle, fields: [ ...(po.approved_by ? [{ label: t('approved_by'), value: , type: 'component' as const }] : []), ...(po.approved_at ? [{ label: t('approved_at'), value: new Date(po.approved_at).toLocaleDateString(i18n.language, { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }), type: 'text' as const }] : []), ...(po.approval_notes ? [{ label: t('approval_notes'), value: po.approval_notes, type: 'text' as const }] : []) ] }); } // Add notes section if present if (po.notes || po.internal_notes) { const notesFields = []; if (po.notes) { notesFields.push({ label: t('order_notes'), value: po.notes, type: 'text' as const }); } if (po.internal_notes) { notesFields.push({ label: t('internal_notes'), value: po.internal_notes, type: 'text' as const }); } sections.push({ title: t('notes'), icon: FileText, fields: notesFields }); } // Add audit trail section if audit data exists if (po.created_by || po.updated_at) { const auditFields = []; if (po.created_by) { auditFields.push({ label: t('created_by'), value: , type: 'component' as const }); } if (po.updated_at) { auditFields.push({ label: t('last_updated'), value: new Date(po.updated_at).toLocaleDateString(i18n.language, { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }), type: 'text' as const }); } sections.push({ title: t('audit_trail'), icon: FileText, fields: auditFields }); } return sections; }; // Component to edit PO items const EditablePurchaseOrderItems: React.FC<{ value: any; onChange?: (value: any) => void }> = ({ value: items, onChange }) => { const handleItemChange = (index: number, field: string, value: any) => { if (!items || !onChange) return; const updatedItems = [...items]; updatedItems[index] = { ...updatedItems[index], [field]: value }; onChange(updatedItems); }; if (!items || items.length === 0) { return (

{t('no_items')}

); } const totalAmount = items.reduce((sum, item) => { const price = parseFloat(item.unit_price) || 0; const quantity = item.ordered_quantity || 0; return sum + (price * quantity); }, 0); return (
{items.map((item: any, index: number) => { const unitPrice = parseFloat(item.unit_price) || 0; const quantity = item.ordered_quantity || 0; const itemTotal = unitPrice * quantity; const productName = item.product_name || `${t('product')} ${index + 1}`; return (

{productName}

{item.product_code && (

{t('sku')}: {item.product_code}

)}

€{itemTotal.toFixed(2)}

{/* Editable fields */}
handleItemChange(index, 'ordered_quantity', parseFloat(e.target.value) || 0)} min="0" step="0.01" className="flex-1 px-3 py-2 border border-[var(--border-secondary)] rounded-md focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)]" />
handleItemChange(index, 'unit_price', e.target.value)} min="0" step="0.01" className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-md focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)]" />
); })}
{t('total')} €{totalAmount.toFixed(2)}
); }; // Build sections for edit mode const buildEditSections = (): EditViewModalSection[] => { if (!po) return []; return [ { title: t('supplier_info'), icon: Building2, fields: [ { label: t('supplier'), value: po.supplier?.name || t('common:unknown'), type: 'text' as const, editable: false, helpText: t('supplier_cannot_modify') } ] }, { title: t('order_details'), icon: Calendar, fields: [ { label: t('priority'), value: formData.priority || po.priority, type: 'select' as const, editable: true, options: priorityOptions, helpText: t('adjust_priority') }, { label: t('required_delivery_date'), value: formData.required_delivery_date || po.required_delivery_date || '', type: 'date' as const, editable: true, helpText: t('delivery_deadline') }, { label: t('notes'), value: formData.notes !== undefined ? formData.notes : (po.notes || ''), type: 'textarea' as const, editable: true, placeholder: t('special_instructions'), span: 2, helpText: t('additional_info') } ] }, { title: t('products'), icon: Package, fields: [ { label: t('products'), value: formData.items || (po.items || []).map((item: PurchaseOrderItem) => ({ id: item.id, inventory_product_id: item.inventory_product_id, product_code: item.product_code || '', product_name: item.product_name || '', ordered_quantity: item.ordered_quantity, unit_of_measure: item.unit_of_measure, unit_price: parseFloat(item.unit_price), })), type: 'component' as const, component: EditablePurchaseOrderItems, span: 2, helpText: t('modify_quantities') } ] } ]; }; // Save handler for edit mode const handleSave = async () => { try { const items = formData.items || []; if (items.length === 0) { throw new Error(t('at_least_one_product')); } // Validate quantities const invalidQuantities = items.some((item: any) => item.ordered_quantity <= 0); if (invalidQuantities) { throw new Error(t('quantities_greater_zero')); } // Validate required fields const invalidProducts = items.some((item: any) => !item.product_name); if (invalidProducts) { throw new Error(t('products_need_names')); } // Prepare the update data const updateData: any = { notes: formData.notes || undefined, priority: formData.priority || undefined, }; // Add delivery date if changed if (formData.required_delivery_date) { updateData.required_delivery_date = formData.required_delivery_date; } // Update purchase order await updatePurchaseOrderMutation.mutateAsync({ tenantId, poId, data: updateData }); // Refetch data and switch back to view mode await refetch(); setMode('view'); } catch (error) { console.error('Error modifying purchase order:', error); throw error; } }; // Build actions for modal footer - only Approve button for pending approval POs const buildActions = () => { if (!po) return undefined; const actions = []; // Show Approve/Reject actions only if explicitly enabled and status is pending approval if (showApprovalActions && po.status === 'pending_approval') { actions.push( { label: t('actions.approve'), icon: CheckCircle, onClick: () => { setApprovalAction('approve'); setApprovalNotes(''); setShowApprovalModal(true); }, variant: 'primary' as const }, { label: t('actions.reject'), icon: X, onClick: () => { setApprovalAction('reject'); setApprovalNotes(''); setShowApprovalModal(true); }, variant: 'outline' as const, destructive: true } ); } return actions.length > 0 ? actions : undefined; }; const sections = useMemo(() => { return mode === 'view' ? buildViewSections() : buildEditSections(); }, [mode, po, formData, i18n.language]); // Handle approval/rejection const handleApprovalAction = async () => { if (!poId) return; try { if (approvalAction === 'approve') { onApprove?.(poId); } else { if (!approvalNotes.trim()) { throw new Error(t('reason_required')); } onReject?.(poId, approvalNotes); } setShowApprovalModal(false); onClose(); // Close the main modal after approval action } catch (error) { console.error('Error in approval action:', error); } }; return ( <> { setMode('view'); onClose(); }} mode={mode} onModeChange={setMode} title={po?.po_number || t('purchase_order')} subtitle={po ? new Date(po.created_at).toLocaleDateString(i18n.language, { year: 'numeric', month: 'long', day: 'numeric' }) : undefined} sections={sections} actions={buildActions()} loading={isLoading} size="lg" // Enable edit mode via standard Edit button (only for pending approval) onEdit={po?.status === 'pending_approval' ? () => setMode('edit') : undefined} // Disable edit mode for POs that are approved, cancelled, or completed disableEdit={po?.status === 'approved' || po?.status === 'cancelled' || po?.status === 'completed'} onSave={mode === 'edit' ? handleSave : undefined} onCancel={mode === 'edit' ? () => setMode('view') : undefined} onFieldChange={handleFieldChange} saveLabel={t('actions.save')} cancelLabel={t('actions.cancel')} /> {/* Approval Modal */} {showApprovalModal && (

{approvalAction === 'approve' ? t('actions.approve') : t('actions.reject')} {t('purchase_order')}