678 lines
24 KiB
TypeScript
678 lines
24 KiB
TypeScript
|
|
// ================================================================
|
|||
|
|
// 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>
|
|||
|
|
);
|
|||
|
|
}
|