Files
bakery-ia/frontend/src/components/domain/procurement/UnifiedPurchaseOrderModal.tsx
Claude 9545cf43b4 Fix loading state in PO details modal
Fixed the loading prop name from 'isLoading' to 'loading' in UnifiedPurchaseOrderModal
to properly display loading state while fetching data from backend. This ensures users
see a loading indicator instead of an empty modal during data fetch.
2025-11-20 18:19:24 +00:00

823 lines
28 KiB
TypeScript

// ================================================================
// 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<UnifiedPurchaseOrderModalProps> = ({
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<Record<string, any>>({});
// 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 (
<div className="text-center py-8 text-[var(--text-secondary)] border-2 border-dashed border-[var(--border-secondary)] rounded-lg">
<Package className="h-12 w-12 mx-auto mb-2 opacity-50" />
<p>{t('no_items')}</p>
</div>
);
}
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 (
<div className="space-y-3">
{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 (
<div
key={item.id || index}
className="p-4 border border-[var(--border-secondary)] rounded-lg bg-[var(--bg-secondary)]/50 space-y-3"
>
<div className="flex justify-between items-start">
<div className="flex-1">
<h4 className="font-semibold text-[var(--text-primary)]">{productName}</h4>
{item.product_code && (
<p className="text-sm text-[var(--text-secondary)]">
{t('sku')}: {item.product_code}
</p>
)}
</div>
<div className="text-right">
<p className="font-bold text-lg text-[var(--color-primary-600)]">
{itemTotal.toFixed(2)}
</p>
</div>
</div>
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<p className="text-[var(--text-secondary)]">{t('quantity')}</p>
<p className="font-medium text-[var(--text-primary)]">
{quantity} {item.unit_of_measure}
</p>
</div>
<div>
<p className="text-[var(--text-secondary)]">{t('unit_price')}</p>
<p className="font-medium text-[var(--text-primary)]">{unitPrice.toFixed(2)}</p>
</div>
</div>
{item.quality_requirements && (
<div className="pt-2 border-t border-[var(--border-secondary)]">
<p className="text-xs text-[var(--text-secondary)]">{t('quality_requirements')}</p>
<p className="text-sm text-[var(--text-primary)]">{item.quality_requirements}</p>
</div>
)}
{item.item_notes && (
<div className="pt-2 border-t border-[var(--border-secondary)]">
<p className="text-xs text-[var(--text-secondary)]">{t('common:notes')}</p>
<p className="text-sm text-[var(--text-primary)]">{item.item_notes}</p>
</div>
)}
</div>
);
})}
<div className="flex justify-between items-center pt-4 border-t-2 border-[var(--border-primary)]">
<span className="font-semibold text-lg text-[var(--text-primary)]">{t('total')}</span>
<span className="font-bold text-2xl text-[var(--color-primary-600)]">{totalAmount.toFixed(2)}</span>
</div>
</div>
);
};
// 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: <PurchaseOrderItemsTable items={po.items || []} />,
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: <UserName userId={po.approved_by} />,
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: <UserName userId={po.created_by} />,
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 (
<div className="text-center py-8 text-[var(--text-secondary)] border-2 border-dashed border-[var(--border-secondary)] rounded-lg">
<Package className="h-12 w-12 mx-auto mb-2 opacity-50" />
<p>{t('no_items')}</p>
</div>
);
}
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 (
<div className="space-y-3">
{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 (
<div
key={item.id || index}
className="p-4 border border-[var(--border-secondary)] rounded-lg bg-[var(--bg-secondary)]/50 space-y-3"
>
<div className="flex justify-between items-start">
<div className="flex-1">
<h4 className="font-semibold text-[var(--text-primary)]">{productName}</h4>
{item.product_code && (
<p className="text-sm text-[var(--text-secondary)]">
{t('sku')}: {item.product_code}
</p>
)}
</div>
<div className="text-right">
<p className="font-bold text-lg text-[var(--color-primary-600)]">
{itemTotal.toFixed(2)}
</p>
</div>
</div>
{/* Editable fields */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs text-[var(--text-secondary)] mb-1">
{t('quantity')}
</label>
<div className="flex gap-2">
<input
type="number"
value={quantity}
onChange={(e) => 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)]"
/>
<select
value={item.unit_of_measure}
onChange={(e) => handleItemChange(index, 'unit_of_measure', e.target.value)}
className="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)]"
>
{unitOptions.map(opt => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
</div>
</div>
<div>
<label className="block text-xs text-[var(--text-secondary)] mb-1">
{t('unit_price')}
</label>
<input
type="number"
value={unitPrice}
onChange={(e) => 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)]"
/>
</div>
</div>
</div>
);
})}
<div className="flex justify-between items-center pt-4 border-t-2 border-[var(--border-primary)]">
<span className="font-semibold text-lg text-[var(--text-primary)]">{t('total')}</span>
<span className="font-bold text-2xl text-[var(--color-primary-600)]">{totalAmount.toFixed(2)}</span>
</div>
</div>
);
};
// 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 (
<>
<EditViewModal
isOpen={isOpen}
onClose={() => {
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 && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg shadow-xl max-w-md w-full mx-4">
<div className="p-6">
<h3 className="text-lg font-semibold mb-4">
{approvalAction === 'approve' ? t('actions.approve') : t('actions.reject')} {t('purchase_order')}
</h3>
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-2">
{approvalAction === 'approve'
? t('approval_notes_optional')
: t('rejection_reason_required')}
</label>
<textarea
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500"
rows={4}
value={approvalNotes}
onChange={(e) => setApprovalNotes(e.target.value)}
placeholder={approvalAction === 'approve'
? t('approval_notes_placeholder')
: t('rejection_reason_placeholder')}
/>
</div>
<div className="flex justify-end gap-2">
<Button
variant="outline"
onClick={() => {
setShowApprovalModal(false);
setApprovalNotes('');
}}
>
{t('actions.cancel')}
</Button>
<Button
onClick={handleApprovalAction}
disabled={updatePurchaseOrderMutation.isPending}
>
{approvalAction === 'approve' ? t('actions.approve') : t('actions.reject')}
</Button>
</div>
</div>
</div>
</div>
)}
</>
);
};