Files
bakery-ia/frontend/src/components/dashboard/StockReceiptModal.tsx
2025-11-27 15:52:40 +01:00

678 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ================================================================
// frontend/src/components/dashboard/StockReceiptModal.tsx
// ================================================================
/**
* Stock Receipt Modal - Lot-Level Tracking
*
* Complete workflow for receiving deliveries with lot-level expiration tracking.
* Critical for food safety compliance.
*
* Features:
* - Multi-line item support (one per ingredient)
* - Lot splitting (e.g., 50kg → 2×25kg lots with different expiration dates)
* - Mandatory expiration dates
* - Quantity validation (lot quantities must sum to actual quantity)
* - Discrepancy tracking (expected vs actual)
* - Draft save functionality
* - Warehouse location tracking
*/
import React, { useState, useEffect } from 'react';
import {
X,
Plus,
Trash2,
Save,
CheckCircle,
AlertTriangle,
Package,
Calendar,
MapPin,
FileText,
Truck,
} from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Button } from '../ui/Button';
// ============================================================
// Types
// ============================================================
export interface StockLot {
id?: string;
lot_number?: string;
supplier_lot_number?: string;
quantity: number;
unit_of_measure: string;
expiration_date: string; // ISO date string (YYYY-MM-DD)
best_before_date?: string;
warehouse_location?: string;
storage_zone?: string;
quality_notes?: string;
}
export interface StockReceiptLineItem {
id?: string;
ingredient_id: string;
ingredient_name: string;
po_line_id?: string;
expected_quantity: number;
actual_quantity: number;
unit_of_measure: string;
has_discrepancy: boolean;
discrepancy_reason?: string;
unit_cost?: number;
total_cost?: number;
lots: StockLot[];
}
export interface StockReceipt {
id?: string;
tenant_id: string;
po_id: string;
po_number?: string;
received_at?: string;
received_by_user_id: string;
status?: 'draft' | 'confirmed' | 'cancelled';
supplier_id?: string;
supplier_name?: string;
notes?: string;
has_discrepancies?: boolean;
line_items: StockReceiptLineItem[];
}
interface StockReceiptModalProps {
isOpen: boolean;
onClose: () => void;
receipt: Partial<StockReceipt>;
mode?: 'create' | 'edit';
onSaveDraft?: (receipt: StockReceipt) => Promise<void>;
onConfirm?: (receipt: StockReceipt) => Promise<void>;
}
// ============================================================
// Helper Functions
// ============================================================
function calculateLotQuantitySum(lots: StockLot[]): number {
return lots.reduce((sum, lot) => sum + (lot.quantity || 0), 0);
}
function hasDiscrepancy(expected: number, actual: number): boolean {
return Math.abs(expected - actual) > 0.01;
}
// ============================================================
// Sub-Components
// ============================================================
interface LotInputProps {
lot: StockLot;
lineItemUoM: string;
onChange: (updatedLot: StockLot) => void;
onRemove: () => void;
canRemove: boolean;
}
function LotInput({ lot, lineItemUoM, onChange, onRemove, canRemove }: LotInputProps) {
const { t } = useTranslation('inventory');
return (
<div
className="p-4 rounded-lg border-2"
style={{
backgroundColor: 'var(--bg-secondary)',
borderColor: 'var(--border-secondary)',
}}
>
{/* Lot Header */}
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<Package className="w-4 h-4" style={{ color: 'var(--color-info)' }} />
<span className="font-semibold text-sm" style={{ color: 'var(--text-primary)' }}>
{t('lot_details')}
</span>
</div>
{canRemove && (
<button
onClick={onRemove}
className="p-1 rounded hover:bg-red-100 transition-colors"
style={{ color: 'var(--color-error)' }}
>
<Trash2 className="w-4 h-4" />
</button>
)}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{/* Quantity (Required) */}
<div>
<label className="block text-sm font-medium mb-1" style={{ color: 'var(--text-secondary)' }}>
{t('quantity')} ({lineItemUoM}) *
</label>
<input
type="number"
step="0.01"
min="0"
value={lot.quantity || ''}
onChange={(e) => onChange({ ...lot, quantity: parseFloat(e.target.value) || 0 })}
className="w-full px-3 py-2 rounded-lg border"
style={{
backgroundColor: 'var(--bg-primary)',
borderColor: 'var(--border-primary)',
color: 'var(--text-primary)',
}}
required
/>
</div>
{/* Expiration Date (Required) */}
<div>
<label className="block text-sm font-medium mb-1" style={{ color: 'var(--text-secondary)' }}>
<Calendar className="w-3 h-3 inline mr-1" />
{t('expiration_date')} *
</label>
<input
type="date"
value={lot.expiration_date || ''}
onChange={(e) => onChange({ ...lot, expiration_date: e.target.value })}
className="w-full px-3 py-2 rounded-lg border"
style={{
backgroundColor: 'var(--bg-primary)',
borderColor: 'var(--border-primary)',
color: 'var(--text-primary)',
}}
required
/>
</div>
{/* Lot Number (Optional) */}
<div>
<label className="block text-sm font-medium mb-1" style={{ color: 'var(--text-secondary)' }}>
{t('lot_number')}
</label>
<input
type="text"
value={lot.lot_number || ''}
onChange={(e) => onChange({ ...lot, lot_number: e.target.value })}
placeholder="LOT-2024-001"
className="w-full px-3 py-2 rounded-lg border"
style={{
backgroundColor: 'var(--bg-primary)',
borderColor: 'var(--border-primary)',
color: 'var(--text-primary)',
}}
/>
</div>
{/* Supplier Lot Number (Optional) */}
<div>
<label className="block text-sm font-medium mb-1" style={{ color: 'var(--text-secondary)' }}>
{t('supplier_lot_number')}
</label>
<input
type="text"
value={lot.supplier_lot_number || ''}
onChange={(e) => onChange({ ...lot, supplier_lot_number: e.target.value })}
placeholder="SUPP-LOT-123"
className="w-full px-3 py-2 rounded-lg border"
style={{
backgroundColor: 'var(--bg-primary)',
borderColor: 'var(--border-primary)',
color: 'var(--text-primary)',
}}
/>
</div>
{/* Warehouse Location (Optional) */}
<div>
<label className="block text-sm font-medium mb-1" style={{ color: 'var(--text-secondary)' }}>
<MapPin className="w-3 h-3 inline mr-1" />
{t('warehouse_location')}
</label>
<input
type="text"
value={lot.warehouse_location || ''}
onChange={(e) => onChange({ ...lot, warehouse_location: e.target.value })}
placeholder="A-01-03"
className="w-full px-3 py-2 rounded-lg border"
style={{
backgroundColor: 'var(--bg-primary)',
borderColor: 'var(--border-primary)',
color: 'var(--text-primary)',
}}
/>
</div>
{/* Storage Zone (Optional) */}
<div>
<label className="block text-sm font-medium mb-1" style={{ color: 'var(--text-secondary)' }}>
{t('storage_zone')}
</label>
<input
type="text"
value={lot.storage_zone || ''}
onChange={(e) => onChange({ ...lot, storage_zone: e.target.value })}
placeholder="Cold Storage"
className="w-full px-3 py-2 rounded-lg border"
style={{
backgroundColor: 'var(--bg-primary)',
borderColor: 'var(--border-primary)',
color: 'var(--text-primary)',
}}
/>
</div>
</div>
{/* Quality Notes (Optional, full width) */}
<div className="mt-3">
<label className="block text-sm font-medium mb-1" style={{ color: 'var(--text-secondary)' }}>
<FileText className="w-3 h-3 inline mr-1" />
{t('quality_notes')}
</label>
<textarea
value={lot.quality_notes || ''}
onChange={(e) => onChange({ ...lot, quality_notes: e.target.value })}
placeholder={t('quality_notes_placeholder')}
rows={2}
className="w-full px-3 py-2 rounded-lg border resize-none"
style={{
backgroundColor: 'var(--bg-primary)',
borderColor: 'var(--border-primary)',
color: 'var(--text-primary)',
}}
/>
</div>
</div>
);
}
// ============================================================
// Main Component
// ============================================================
export function StockReceiptModal({
isOpen,
onClose,
receipt: initialReceipt,
mode = 'create',
onSaveDraft,
onConfirm,
}: StockReceiptModalProps) {
const { t } = useTranslation(['inventory', 'common']);
const [receipt, setReceipt] = useState<Partial<StockReceipt>>(initialReceipt);
const [validationErrors, setValidationErrors] = useState<Record<string, string>>({});
const [isSaving, setIsSaving] = useState(false);
useEffect(() => {
setReceipt(initialReceipt);
}, [initialReceipt]);
if (!isOpen) return null;
// ============================================================
// Handlers
// ============================================================
const updateLineItem = (index: number, updates: Partial<StockReceiptLineItem>) => {
const newLineItems = [...(receipt.line_items || [])];
newLineItems[index] = { ...newLineItems[index], ...updates };
// Auto-detect discrepancy
if (updates.actual_quantity !== undefined || updates.expected_quantity !== undefined) {
const item = newLineItems[index];
item.has_discrepancy = hasDiscrepancy(item.expected_quantity, item.actual_quantity);
}
setReceipt({ ...receipt, line_items: newLineItems });
};
const updateLot = (lineItemIndex: number, lotIndex: number, updatedLot: StockLot) => {
const newLineItems = [...(receipt.line_items || [])];
newLineItems[lineItemIndex].lots[lotIndex] = updatedLot;
setReceipt({ ...receipt, line_items: newLineItems });
};
const addLot = (lineItemIndex: number) => {
const lineItem = receipt.line_items?.[lineItemIndex];
if (!lineItem) return;
const newLot: StockLot = {
quantity: 0,
unit_of_measure: lineItem.unit_of_measure,
expiration_date: '',
};
const newLineItems = [...(receipt.line_items || [])];
newLineItems[lineItemIndex].lots.push(newLot);
setReceipt({ ...receipt, line_items: newLineItems });
};
const removeLot = (lineItemIndex: number, lotIndex: number) => {
const newLineItems = [...(receipt.line_items || [])];
newLineItems[lineItemIndex].lots.splice(lotIndex, 1);
setReceipt({ ...receipt, line_items: newLineItems });
};
const validate = (): boolean => {
const errors: Record<string, string> = {};
if (!receipt.line_items || receipt.line_items.length === 0) {
errors.general = t('inventory:validation.no_line_items');
setValidationErrors(errors);
return false;
}
receipt.line_items.forEach((item, idx) => {
// Check lots exist
if (!item.lots || item.lots.length === 0) {
errors[`line_${idx}_lots`] = t('inventory:validation.at_least_one_lot');
}
// Check lot quantities sum to actual quantity
const lotSum = calculateLotQuantitySum(item.lots);
if (Math.abs(lotSum - item.actual_quantity) > 0.01) {
errors[`line_${idx}_quantity`] = t('inventory:validation.lot_quantity_mismatch', {
expected: item.actual_quantity,
actual: lotSum,
});
}
// Check expiration dates
item.lots.forEach((lot, lotIdx) => {
if (!lot.expiration_date) {
errors[`line_${idx}_lot_${lotIdx}_exp`] = t('inventory:validation.expiration_required');
}
if (!lot.quantity || lot.quantity <= 0) {
errors[`line_${idx}_lot_${lotIdx}_qty`] = t('inventory:validation.quantity_required');
}
});
});
setValidationErrors(errors);
return Object.keys(errors).length === 0;
};
const handleSaveDraft = async () => {
if (!onSaveDraft) return;
setIsSaving(true);
try {
await onSaveDraft(receipt as StockReceipt);
onClose();
} catch (error) {
console.error('Failed to save draft:', error);
setValidationErrors({ general: t('inventory:errors.save_failed') });
} finally {
setIsSaving(false);
}
};
const handleConfirm = async () => {
if (!validate()) return;
if (!onConfirm) return;
setIsSaving(true);
try {
await onConfirm(receipt as StockReceipt);
onClose();
} catch (error) {
console.error('Failed to confirm receipt:', error);
setValidationErrors({ general: t('inventory:errors.confirm_failed') });
} finally {
setIsSaving(false);
}
};
// ============================================================
// Render
// ============================================================
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50">
<div
className="w-full max-w-5xl max-h-[90vh] overflow-hidden rounded-xl shadow-2xl flex flex-col"
style={{ backgroundColor: 'var(--bg-primary)' }}
>
{/* Header */}
<div
className="flex items-center justify-between p-6 border-b"
style={{ borderColor: 'var(--border-primary)' }}
>
<div className="flex items-center gap-3">
<Truck className="w-6 h-6" style={{ color: 'var(--color-primary)' }} />
<div>
<h2 className="text-2xl font-bold" style={{ color: 'var(--text-primary)' }}>
{t('inventory:stock_receipt_title')}
</h2>
{receipt.po_number && (
<p className="text-sm mt-1" style={{ color: 'var(--text-secondary)' }}>
{t('inventory:purchase_order')}: {receipt.po_number}
</p>
)}
</div>
</div>
<button
onClick={onClose}
className="p-2 rounded-lg hover:bg-gray-100 transition-colors"
style={{ color: 'var(--text-secondary)' }}
>
<X className="w-6 h-6" />
</button>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-6">
{/* Supplier Info */}
{receipt.supplier_name && (
<div
className="mb-6 p-4 rounded-lg border"
style={{
backgroundColor: 'var(--bg-secondary)',
borderColor: 'var(--border-secondary)',
}}
>
<h3 className="font-semibold mb-2" style={{ color: 'var(--text-primary)' }}>
{t('inventory:supplier_info')}
</h3>
<p style={{ color: 'var(--text-secondary)' }}>{receipt.supplier_name}</p>
</div>
)}
{/* Validation Errors */}
{validationErrors.general && (
<div
className="mb-6 p-4 rounded-lg border-2 flex items-start gap-3"
style={{
backgroundColor: 'var(--color-error-50)',
borderColor: 'var(--color-error-300)',
}}
>
<AlertTriangle className="w-5 h-5 flex-shrink-0 mt-0.5" style={{ color: 'var(--color-error-700)' }} />
<div>
<p className="font-semibold" style={{ color: 'var(--color-error-900)' }}>
{validationErrors.general}
</p>
</div>
</div>
)}
{/* Line Items */}
{receipt.line_items?.map((item, idx) => (
<div
key={idx}
className="mb-6 p-6 rounded-xl border-2"
style={{
backgroundColor: 'var(--bg-primary)',
borderColor: item.has_discrepancy ? 'var(--color-warning-300)' : 'var(--border-primary)',
}}
>
{/* Line Item Header */}
<div className="flex items-start justify-between mb-4">
<div>
<h3 className="text-lg font-bold mb-1" style={{ color: 'var(--text-primary)' }}>
{item.ingredient_name}
</h3>
<div className="flex items-center gap-4 text-sm" style={{ color: 'var(--text-secondary)' }}>
<span>
{t('inventory:expected')}: {item.expected_quantity} {item.unit_of_measure}
</span>
<span></span>
<span className={item.has_discrepancy ? 'font-semibold' : ''}>
{t('inventory:actual')}: {item.actual_quantity} {item.unit_of_measure}
</span>
</div>
{item.has_discrepancy && (
<div className="mt-2 flex items-center gap-2">
<AlertTriangle className="w-4 h-4" style={{ color: 'var(--color-warning-700)' }} />
<span className="text-sm font-semibold" style={{ color: 'var(--color-warning-900)' }}>
{t('inventory:discrepancy_detected')}
</span>
</div>
)}
</div>
{/* Lot Quantity Summary */}
<div className="text-right">
<div className="text-sm font-medium mb-1" style={{ color: 'var(--text-secondary)' }}>
{t('inventory:lot_total')}
</div>
<div
className="text-2xl font-bold"
style={{
color:
Math.abs(calculateLotQuantitySum(item.lots) - item.actual_quantity) < 0.01
? 'var(--color-success)'
: 'var(--color-error)',
}}
>
{calculateLotQuantitySum(item.lots).toFixed(2)} {item.unit_of_measure}
</div>
</div>
</div>
{/* Discrepancy Reason */}
{item.has_discrepancy && (
<div className="mb-4">
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-secondary)' }}>
{t('inventory:discrepancy_reason')}
</label>
<textarea
value={item.discrepancy_reason || ''}
onChange={(e) => updateLineItem(idx, { discrepancy_reason: e.target.value })}
placeholder={t('inventory:discrepancy_reason_placeholder')}
rows={2}
className="w-full px-3 py-2 rounded-lg border resize-none"
style={{
backgroundColor: 'var(--bg-primary)',
borderColor: 'var(--border-primary)',
color: 'var(--text-primary)',
}}
/>
</div>
)}
{/* Lots */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<h4 className="font-semibold" style={{ color: 'var(--text-primary)' }}>
{t('inventory:lots')} ({item.lots.length})
</h4>
</div>
{item.lots.map((lot, lotIdx) => (
<LotInput
key={lotIdx}
lot={lot}
lineItemUoM={item.unit_of_measure}
onChange={(updatedLot) => updateLot(idx, lotIdx, updatedLot)}
onRemove={() => removeLot(idx, lotIdx)}
canRemove={item.lots.length > 1}
/>
))}
{/* Add Lot Button */}
<button
onClick={() => addLot(idx)}
className="w-full flex items-center justify-center gap-2 px-4 py-3 rounded-lg border-2 border-dashed transition-colors hover:border-solid"
style={{
borderColor: 'var(--border-primary)',
color: 'var(--color-primary)',
backgroundColor: 'transparent',
}}
>
<Plus className="w-4 h-4" />
<span className="font-semibold">{t('inventory:add_lot')}</span>
</button>
</div>
{/* Validation Error for this line */}
{(validationErrors[`line_${idx}_lots`] ||
validationErrors[`line_${idx}_quantity`]) && (
<div
className="mt-4 p-3 rounded-lg border flex items-start gap-2"
style={{
backgroundColor: 'var(--color-error-50)',
borderColor: 'var(--color-error-300)',
}}
>
<AlertTriangle className="w-4 h-4 flex-shrink-0 mt-0.5" style={{ color: 'var(--color-error-700)' }} />
<p className="text-sm" style={{ color: 'var(--color-error-900)' }}>
{validationErrors[`line_${idx}_lots`] ||
validationErrors[`line_${idx}_quantity`]}
</p>
</div>
)}
</div>
))}
{/* General Notes */}
<div className="mt-6">
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-secondary)' }}>
{t('inventory:receipt_notes')}
</label>
<textarea
value={receipt.notes || ''}
onChange={(e) => setReceipt({ ...receipt, notes: e.target.value })}
placeholder={t('inventory:receipt_notes_placeholder')}
rows={3}
className="w-full px-3 py-2 rounded-lg border resize-none"
style={{
backgroundColor: 'var(--bg-primary)',
borderColor: 'var(--border-primary)',
color: 'var(--text-primary)',
}}
/>
</div>
</div>
{/* Footer */}
<div
className="flex items-center justify-between p-6 border-t"
style={{ borderColor: 'var(--border-primary)' }}
>
<Button variant="ghost" onClick={onClose} disabled={isSaving}>
{t('common:actions.cancel')}
</Button>
<div className="flex items-center gap-3">
{onSaveDraft && (
<Button variant="secondary" onClick={handleSaveDraft} disabled={isSaving}>
<Save className="w-4 h-4 mr-2" />
{t('common:actions.save_draft')}
</Button>
)}
{onConfirm && (
<Button variant="default" onClick={handleConfirm} disabled={isSaving}>
<CheckCircle className="w-4 h-4 mr-2" />
{t('common:actions.confirm_receipt')}
</Button>
)}
</div>
</div>
</div>
</div>
);
}