Improve the inventory page 3

This commit is contained in:
Urtzi Alfaro
2025-09-18 08:06:32 +02:00
parent dcb3ce441b
commit ae77a0e1c5
31 changed files with 2376 additions and 1774 deletions

View File

@@ -65,9 +65,7 @@ export interface IngredientCreate {
low_stock_threshold: number; low_stock_threshold: number;
max_stock_level?: number; max_stock_level?: number;
reorder_point: number; reorder_point: number;
shelf_life_days?: number; shelf_life_days?: number; // Default shelf life only
requires_refrigeration?: boolean;
requires_freezing?: boolean;
is_seasonal?: boolean; is_seasonal?: boolean;
supplier_id?: string; supplier_id?: string;
average_cost?: number; average_cost?: number;
@@ -89,13 +87,7 @@ export interface IngredientUpdate {
reorder_point?: number; reorder_point?: number;
reorder_quantity?: number; reorder_quantity?: number;
max_stock_level?: number; max_stock_level?: number;
requires_refrigeration?: boolean; shelf_life_days?: number; // Default shelf life only
requires_freezing?: boolean;
storage_temperature_min?: number;
storage_temperature_max?: number;
storage_humidity_max?: number;
shelf_life_days?: number;
storage_instructions?: string;
is_active?: boolean; is_active?: boolean;
is_perishable?: boolean; is_perishable?: boolean;
is_seasonal?: boolean; is_seasonal?: boolean;
@@ -121,13 +113,7 @@ export interface IngredientResponse {
reorder_point: number; reorder_point: number;
reorder_quantity: number; reorder_quantity: number;
max_stock_level?: number; max_stock_level?: number;
requires_refrigeration: boolean; shelf_life_days?: number; // Default shelf life only
requires_freezing: boolean;
storage_temperature_min?: number;
storage_temperature_max?: number;
storage_humidity_max?: number;
shelf_life_days?: number;
storage_instructions?: string;
is_active: boolean; is_active: boolean;
is_perishable: boolean; is_perishable: boolean;
is_seasonal?: boolean; is_seasonal?: boolean;
@@ -149,38 +135,81 @@ export interface IngredientResponse {
// Stock Management Types // Stock Management Types
export interface StockCreate { export interface StockCreate {
ingredient_id: string; ingredient_id: string;
batch_number?: string;
lot_number?: string;
supplier_batch_ref?: string;
// Production stage tracking
production_stage?: ProductionStage; production_stage?: ProductionStage;
transformation_reference?: string; transformation_reference?: string;
quantity: number;
unit_price: number; current_quantity: number;
received_date?: string;
expiration_date?: string; expiration_date?: string;
batch_number?: string; best_before_date?: string;
supplier_id?: string;
purchase_order_reference?: string;
// Stage-specific expiration fields // Stage-specific expiration fields
original_expiration_date?: string; original_expiration_date?: string;
transformation_date?: string; transformation_date?: string;
final_expiration_date?: string; final_expiration_date?: string;
notes?: string; unit_cost?: number;
storage_location?: string;
warehouse_zone?: string;
shelf_position?: string;
quality_status?: string;
// Batch-specific storage requirements
requires_refrigeration?: boolean;
requires_freezing?: boolean;
storage_temperature_min?: number;
storage_temperature_max?: number;
storage_humidity_max?: number;
shelf_life_days?: number;
storage_instructions?: string;
// Optional supplier reference
supplier_id?: string;
} }
export interface StockUpdate { export interface StockUpdate {
batch_number?: string;
lot_number?: string;
supplier_batch_ref?: string;
// Production stage tracking
production_stage?: ProductionStage; production_stage?: ProductionStage;
transformation_reference?: string; transformation_reference?: string;
quantity?: number;
unit_price?: number; current_quantity?: number;
reserved_quantity?: number;
received_date?: string;
expiration_date?: string; expiration_date?: string;
batch_number?: string; best_before_date?: string;
// Stage-specific expiration fields // Stage-specific expiration fields
original_expiration_date?: string; original_expiration_date?: string;
transformation_date?: string; transformation_date?: string;
final_expiration_date?: string; final_expiration_date?: string;
unit_cost?: number;
storage_location?: string;
warehouse_zone?: string;
shelf_position?: string;
quality_status?: string;
notes?: string; notes?: string;
is_available?: boolean; is_available?: boolean;
// Batch-specific storage requirements
requires_refrigeration?: boolean;
requires_freezing?: boolean;
storage_temperature_min?: number;
storage_temperature_max?: number;
storage_humidity_max?: number;
shelf_life_days?: number;
storage_instructions?: string;
} }
export interface StockResponse { export interface StockResponse {
@@ -209,6 +238,7 @@ export interface StockResponse {
batch_number?: string; batch_number?: string;
supplier_id?: string; supplier_id?: string;
purchase_order_reference?: string; purchase_order_reference?: string;
storage_location?: string;
// Stage-specific expiration fields // Stage-specific expiration fields
original_expiration_date?: string; original_expiration_date?: string;
@@ -219,6 +249,16 @@ export interface StockResponse {
is_available: boolean; is_available: boolean;
is_expired: boolean; is_expired: boolean;
days_until_expiry?: number; days_until_expiry?: number;
// Batch-specific storage requirements
requires_refrigeration: boolean;
requires_freezing: boolean;
storage_temperature_min?: number;
storage_temperature_max?: number;
storage_humidity_max?: number;
shelf_life_days?: number;
storage_instructions?: string;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
created_by?: string; created_by?: string;

View File

@@ -1,5 +1,5 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { Plus, Package, Euro, Calendar, FileText } from 'lucide-react'; import { Plus, Package, Euro, Calendar, FileText, Thermometer } from 'lucide-react';
import { StatusModal } from '../../ui/StatusModal/StatusModal'; import { StatusModal } from '../../ui/StatusModal/StatusModal';
import { IngredientResponse, StockCreate } from '../../../api/types/inventory'; import { IngredientResponse, StockCreate } from '../../../api/types/inventory';
import { Button } from '../../ui/Button'; import { Button } from '../../ui/Button';
@@ -24,35 +24,51 @@ export const AddStockModal: React.FC<AddStockModalProps> = ({
}) => { }) => {
const [formData, setFormData] = useState<Partial<StockCreate>>({ const [formData, setFormData] = useState<Partial<StockCreate>>({
ingredient_id: ingredient.id, ingredient_id: ingredient.id,
quantity: 0, current_quantity: 0,
unit_price: Number(ingredient.average_cost) || 0, unit_cost: Number(ingredient.average_cost) || 0,
expiration_date: '', expiration_date: '',
batch_number: '', batch_number: '',
supplier_id: '', supplier_id: '',
purchase_order_reference: '', storage_location: '',
requires_refrigeration: false,
requires_freezing: false,
storage_temperature_min: undefined,
storage_temperature_max: undefined,
storage_humidity_max: undefined,
shelf_life_days: undefined,
storage_instructions: '',
notes: '' notes: ''
}); });
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [mode, setMode] = useState<'overview' | 'edit'>('edit'); const [mode, setMode] = useState<'overview' | 'edit'>('edit');
const handleFieldChange = (_sectionIndex: number, fieldIndex: number, value: string | number) => { const handleFieldChange = (sectionIndex: number, fieldIndex: number, value: string | number | boolean) => {
const fields = ['quantity', 'unit_price', 'expiration_date', 'batch_number', 'supplier_id', 'purchase_order_reference', 'notes']; const fieldMappings = [
const fieldName = fields[fieldIndex] as keyof typeof formData; // Basic Stock Information section
['current_quantity', 'unit_cost', 'expiration_date'],
// Additional Information section
['batch_number', 'supplier_id', 'storage_location', 'notes'],
// Storage Requirements section
['requires_refrigeration', 'requires_freezing', 'storage_temperature_min', 'storage_temperature_max', 'storage_humidity_max', 'shelf_life_days', 'storage_instructions']
];
const fieldName = fieldMappings[sectionIndex]?.[fieldIndex] as keyof typeof formData;
if (fieldName) {
setFormData(prev => ({ setFormData(prev => ({
...prev, ...prev,
[fieldName]: value [fieldName]: value
})); }));
}
}; };
const handleSave = async () => { const handleSave = async () => {
if (!formData.quantity || formData.quantity <= 0) { if (!formData.current_quantity || formData.current_quantity <= 0) {
alert('Por favor, ingresa una cantidad válida'); alert('Por favor, ingresa una cantidad válida');
return; return;
} }
if (!formData.unit_price || formData.unit_price <= 0) { if (!formData.unit_cost || formData.unit_cost <= 0) {
alert('Por favor, ingresa un precio unitario válido'); alert('Por favor, ingresa un precio unitario válido');
return; return;
} }
@@ -61,12 +77,19 @@ export const AddStockModal: React.FC<AddStockModalProps> = ({
try { try {
const stockData: StockCreate = { const stockData: StockCreate = {
ingredient_id: ingredient.id, ingredient_id: ingredient.id,
quantity: Number(formData.quantity), current_quantity: Number(formData.current_quantity),
unit_price: Number(formData.unit_price), unit_cost: Number(formData.unit_cost),
expiration_date: formData.expiration_date || undefined, expiration_date: formData.expiration_date || undefined,
batch_number: formData.batch_number || undefined, batch_number: formData.batch_number || undefined,
supplier_id: formData.supplier_id || undefined, supplier_id: formData.supplier_id || undefined,
purchase_order_reference: formData.purchase_order_reference || undefined, storage_location: formData.storage_location || undefined,
requires_refrigeration: formData.requires_refrigeration || false,
requires_freezing: formData.requires_freezing || false,
storage_temperature_min: formData.storage_temperature_min ? Number(formData.storage_temperature_min) : undefined,
storage_temperature_max: formData.storage_temperature_max ? Number(formData.storage_temperature_max) : undefined,
storage_humidity_max: formData.storage_humidity_max ? Number(formData.storage_humidity_max) : undefined,
shelf_life_days: formData.shelf_life_days ? Number(formData.shelf_life_days) : undefined,
storage_instructions: formData.storage_instructions || undefined,
notes: formData.notes || undefined notes: formData.notes || undefined
}; };
@@ -77,12 +100,19 @@ export const AddStockModal: React.FC<AddStockModalProps> = ({
// Reset form // Reset form
setFormData({ setFormData({
ingredient_id: ingredient.id, ingredient_id: ingredient.id,
quantity: 0, current_quantity: 0,
unit_price: Number(ingredient.average_cost) || 0, unit_cost: Number(ingredient.average_cost) || 0,
expiration_date: '', expiration_date: '',
batch_number: '', batch_number: '',
supplier_id: '', supplier_id: '',
purchase_order_reference: '', storage_location: '',
requires_refrigeration: false,
requires_freezing: false,
storage_temperature_min: undefined,
storage_temperature_max: undefined,
storage_humidity_max: undefined,
shelf_life_days: undefined,
storage_instructions: '',
notes: '' notes: ''
}); });
@@ -96,13 +126,15 @@ export const AddStockModal: React.FC<AddStockModalProps> = ({
}; };
const currentStock = Number(ingredient.current_stock) || 0; const currentStock = Number(ingredient.current_stock) || 0;
const newTotal = currentStock + (Number(formData.quantity) || 0); const newTotal = currentStock + (Number(formData.current_quantity) || 0);
const totalValue = (Number(formData.quantity) || 0) * (Number(formData.unit_price) || 0); const totalValue = (Number(formData.current_quantity) || 0) * (Number(formData.unit_cost) || 0);
const statusConfig = { const statusConfig = {
color: statusColors.normal.primary, color: statusColors.inProgress.primary,
text: 'Agregar Stock', text: 'Agregar Stock',
icon: Plus icon: Plus,
isCritical: false,
isHighlight: true
}; };
const sections = [ const sections = [
@@ -112,7 +144,7 @@ export const AddStockModal: React.FC<AddStockModalProps> = ({
fields: [ fields: [
{ {
label: `Cantidad (${ingredient.unit_of_measure})`, label: `Cantidad (${ingredient.unit_of_measure})`,
value: formData.quantity || 0, value: formData.current_quantity || 0,
type: 'number' as const, type: 'number' as const,
editable: true, editable: true,
required: true, required: true,
@@ -120,7 +152,7 @@ export const AddStockModal: React.FC<AddStockModalProps> = ({
}, },
{ {
label: 'Precio Unitario', label: 'Precio Unitario',
value: formData.unit_price || 0, value: formData.unit_cost || 0,
type: 'currency' as const, type: 'currency' as const,
editable: true, editable: true,
required: true, required: true,
@@ -154,11 +186,11 @@ export const AddStockModal: React.FC<AddStockModalProps> = ({
placeholder: 'Ej: PROV001' placeholder: 'Ej: PROV001'
}, },
{ {
label: 'Referencia de Pedido', label: 'Ubicación de Almacenamiento',
value: formData.purchase_order_reference || '', value: formData.storage_location || '',
type: 'text' as const, type: 'text' as const,
editable: true, editable: true,
placeholder: 'Ej: PO-2024-001', placeholder: 'Ej: Estante A-3',
span: 2 as const span: 2 as const
}, },
{ {
@@ -172,25 +204,55 @@ export const AddStockModal: React.FC<AddStockModalProps> = ({
] ]
}, },
{ {
title: 'Resumen', title: 'Requisitos de Almacenamiento',
icon: Euro, icon: Thermometer,
fields: [ fields: [
{ {
label: 'Stock Actual', label: 'Requiere Refrigeración',
value: `${currentStock} ${ingredient.unit_of_measure}`, value: formData.requires_refrigeration || false,
span: 1 as const type: 'boolean' as const,
editable: true
}, },
{ {
label: 'Nuevo Total', label: 'Requiere Congelación',
value: `${newTotal} ${ingredient.unit_of_measure}`, value: formData.requires_freezing || false,
highlight: true, type: 'boolean' as const,
span: 1 as const editable: true
}, },
{ {
label: 'Valor de la Entrada', label: 'Temperatura Mínima (°C)',
value: `${totalValue.toFixed(2)}`, value: formData.storage_temperature_min || '',
type: 'currency' as const, type: 'number' as const,
highlight: true, editable: true,
placeholder: 'Ej: 2'
},
{
label: 'Temperatura Máxima (°C)',
value: formData.storage_temperature_max || '',
type: 'number' as const,
editable: true,
placeholder: 'Ej: 8'
},
{
label: 'Humedad Máxima (%)',
value: formData.storage_humidity_max || '',
type: 'number' as const,
editable: true,
placeholder: 'Ej: 60'
},
{
label: 'Vida Útil (días)',
value: formData.shelf_life_days || '',
type: 'number' as const,
editable: true,
placeholder: 'Ej: 30'
},
{
label: 'Instrucciones de Almacenamiento',
value: formData.storage_instructions || '',
type: 'text' as const,
editable: true,
placeholder: 'Instrucciones específicas...',
span: 2 as const span: 2 as const
} }
] ]
@@ -208,7 +270,7 @@ export const AddStockModal: React.FC<AddStockModalProps> = ({
label: 'Agregar Stock', label: 'Agregar Stock',
variant: 'primary' as const, variant: 'primary' as const,
onClick: handleSave, onClick: handleSave,
disabled: loading || !formData.quantity || !formData.unit_price, disabled: loading || !formData.current_quantity || !formData.unit_cost,
loading loading
} }
]; ];

View File

@@ -0,0 +1,450 @@
import React, { useState } from 'react';
import { Package, AlertTriangle, Clock, Archive, Thermometer, Plus, Edit, Trash2, CheckCircle, X, Save } from 'lucide-react';
import { StatusModal } from '../../ui/StatusModal/StatusModal';
import { IngredientResponse, StockResponse, StockUpdate } from '../../../api/types/inventory';
import { formatters } from '../../ui/Stats/StatsPresets';
import { statusColors } from '../../../styles/colors';
import { Button } from '../../ui/Button';
interface BatchModalProps {
isOpen: boolean;
onClose: () => void;
ingredient: IngredientResponse;
batches: StockResponse[];
loading?: boolean;
onAddBatch?: () => void;
onEditBatch?: (batchId: string, updateData: StockUpdate) => Promise<void>;
onMarkAsWaste?: (batchId: string) => Promise<void>;
}
/**
* BatchModal - Card-based batch management modal
* Mobile-friendly design with edit and waste marking functionality
*/
export const BatchModal: React.FC<BatchModalProps> = ({
isOpen,
onClose,
ingredient,
batches = [],
loading = false,
onAddBatch,
onEditBatch,
onMarkAsWaste
}) => {
const [editingBatch, setEditingBatch] = useState<string | null>(null);
const [editData, setEditData] = useState<StockUpdate>({});
const [isSubmitting, setIsSubmitting] = useState(false);
// Get batch status based on expiration and availability
const getBatchStatus = (batch: StockResponse) => {
if (!batch.is_available) {
return {
label: 'No Disponible',
color: statusColors.cancelled.primary,
icon: X,
isCritical: true
};
}
if (batch.is_expired) {
return {
label: 'Vencido',
color: statusColors.expired.primary,
icon: AlertTriangle,
isCritical: true
};
}
if (!batch.expiration_date) {
return {
label: 'Sin Vencimiento',
color: statusColors.other.primary,
icon: Archive,
isCritical: false
};
}
const today = new Date();
const expirationDate = new Date(batch.expiration_date);
const daysUntilExpiry = Math.ceil((expirationDate.getTime() - today.getTime()) / (1000 * 60 * 60 * 24));
if (daysUntilExpiry <= 0) {
return {
label: 'Vencido',
color: statusColors.expired.primary,
icon: AlertTriangle,
isCritical: true
};
} else if (daysUntilExpiry <= 3) {
return {
label: 'Vence Pronto',
color: statusColors.low.primary,
icon: AlertTriangle,
isCritical: true
};
} else if (daysUntilExpiry <= 7) {
return {
label: 'Por Vencer',
color: statusColors.pending.primary,
icon: Clock,
isCritical: false
};
} else {
return {
label: 'Fresco',
color: statusColors.completed.primary,
icon: CheckCircle,
isCritical: false
};
}
};
const handleEditStart = (batch: StockResponse) => {
setEditingBatch(batch.id);
setEditData({
current_quantity: batch.current_quantity,
expiration_date: batch.expiration_date,
storage_location: batch.storage_location || '',
requires_refrigeration: batch.requires_refrigeration,
requires_freezing: batch.requires_freezing,
storage_temperature_min: batch.storage_temperature_min,
storage_temperature_max: batch.storage_temperature_max,
storage_humidity_max: batch.storage_humidity_max,
shelf_life_days: batch.shelf_life_days,
storage_instructions: batch.storage_instructions || ''
});
};
const handleEditCancel = () => {
setEditingBatch(null);
setEditData({});
};
const handleEditSave = async (batchId: string) => {
if (!onEditBatch) return;
setIsSubmitting(true);
try {
await onEditBatch(batchId, editData);
setEditingBatch(null);
setEditData({});
} catch (error) {
console.error('Error updating batch:', error);
} finally {
setIsSubmitting(false);
}
};
const handleMarkAsWaste = async (batchId: string) => {
if (!onMarkAsWaste) return;
const confirmed = window.confirm('¿Está seguro que desea marcar este lote como desperdicio? Esta acción no se puede deshacer.');
if (!confirmed) return;
setIsSubmitting(true);
try {
await onMarkAsWaste(batchId);
} catch (error) {
console.error('Error marking batch as waste:', error);
} finally {
setIsSubmitting(false);
}
};
const statusConfig = {
color: statusColors.inProgress.primary,
text: `${batches.length} lotes`,
icon: Package
};
// Create card-based batch list
const batchCards = batches.length > 0 ? (
<div className="space-y-4">
{batches.map((batch) => {
const status = getBatchStatus(batch);
const StatusIcon = status.icon;
const isEditing = editingBatch === batch.id;
return (
<div
key={batch.id}
className="bg-[var(--surface-secondary)] rounded-lg border border-[var(--border-primary)] overflow-hidden"
style={{
borderColor: status.isCritical ? `${status.color}40` : undefined,
backgroundColor: status.isCritical ? `${status.color}05` : undefined
}}
>
{/* Header */}
<div className="p-4 border-b border-[var(--border-secondary)]">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div
className="flex-shrink-0 p-2 rounded-lg"
style={{ backgroundColor: `${status.color}15` }}
>
<StatusIcon
className="w-5 h-5"
style={{ color: status.color }}
/>
</div>
<div>
<h3 className="font-semibold text-[var(--text-primary)]">
Lote #{batch.batch_number || 'Sin número'}
</h3>
<div
className="text-sm font-medium"
style={{ color: status.color }}
>
{status.label}
</div>
</div>
</div>
<div className="flex items-center gap-2">
{!isEditing && (
<>
<Button
variant="outline"
size="sm"
onClick={() => handleEditStart(batch)}
disabled={isSubmitting}
>
<Edit className="w-4 h-4" />
</Button>
{status.isCritical && batch.is_available && (
<Button
variant="outline"
size="sm"
onClick={() => handleMarkAsWaste(batch.id)}
disabled={isSubmitting}
className="text-red-600 border-red-300 hover:bg-red-50"
>
<Trash2 className="w-4 h-4" />
</Button>
)}
</>
)}
{isEditing && (
<>
<Button
variant="outline"
size="sm"
onClick={handleEditCancel}
disabled={isSubmitting}
>
<X className="w-4 h-4" />
</Button>
<Button
variant="primary"
size="sm"
onClick={() => handleEditSave(batch.id)}
disabled={isSubmitting}
isLoading={isSubmitting}
>
<Save className="w-4 h-4" />
</Button>
</>
)}
</div>
</div>
</div>
{/* Content */}
<div className="p-4 space-y-4">
{/* Basic Info */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-xs font-medium text-[var(--text-tertiary)] mb-1 block">
Cantidad Actual
</label>
{isEditing ? (
<input
type="number"
value={editData.current_quantity || ''}
onChange={(e) => setEditData(prev => ({ ...prev, current_quantity: Number(e.target.value) }))}
className="w-full px-3 py-2 border border-[var(--border-color)] bg-[var(--background-primary)] text-[var(--text-primary)] rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
) : (
<div className="text-lg font-bold text-[var(--text-primary)]">
{batch.current_quantity} {ingredient.unit_of_measure}
</div>
)}
</div>
<div>
<label className="text-xs font-medium text-[var(--text-tertiary)] mb-1 block">
Valor Total
</label>
<div className="text-lg font-bold text-[var(--text-primary)]">
{formatters.currency(Number(batch.total_cost || 0))}
</div>
</div>
</div>
{/* Dates */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="text-xs font-medium text-[var(--text-tertiary)] mb-1 block">
Fecha de Vencimiento
</label>
{isEditing ? (
<input
type="date"
value={editData.expiration_date ? new Date(editData.expiration_date).toISOString().split('T')[0] : ''}
onChange={(e) => setEditData(prev => ({ ...prev, expiration_date: e.target.value }))}
className="w-full px-3 py-2 border border-[var(--border-color)] bg-[var(--background-primary)] text-[var(--text-primary)] rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
) : (
<div className="text-sm text-[var(--text-secondary)]">
{batch.expiration_date
? new Date(batch.expiration_date).toLocaleDateString('es-ES')
: 'Sin vencimiento'
}
</div>
)}
</div>
<div>
<label className="text-xs font-medium text-[var(--text-tertiary)] mb-1 block">
Ubicación
</label>
{isEditing ? (
<input
type="text"
value={editData.storage_location || ''}
onChange={(e) => setEditData(prev => ({ ...prev, storage_location: e.target.value }))}
placeholder="Ubicación del lote"
className="w-full px-3 py-2 border border-[var(--border-color)] bg-[var(--background-primary)] text-[var(--text-primary)] rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
) : (
<div className="text-sm text-[var(--text-secondary)]">
{batch.storage_location || 'No especificada'}
</div>
)}
</div>
</div>
{/* Storage Requirements */}
<div className="pt-3 border-t border-[var(--border-secondary)]">
<div className="flex items-center gap-2 mb-3">
<Thermometer className="w-4 h-4 text-[var(--text-tertiary)]" />
<span className="text-sm font-medium text-[var(--text-tertiary)]">
Almacenamiento
</span>
</div>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3 text-xs">
<div className="flex items-center gap-1">
<span className="text-[var(--text-tertiary)]">Tipo:</span>
<span className="font-medium text-[var(--text-secondary)]">
{batch.requires_refrigeration ? 'Refrigeración' :
batch.requires_freezing ? 'Congelación' : 'Ambiente'}
</span>
</div>
{(batch.storage_temperature_min || batch.storage_temperature_max) && (
<div className="flex items-center gap-1">
<span className="text-[var(--text-tertiary)]">Temp:</span>
<span className="font-medium text-[var(--text-secondary)]">
{batch.storage_temperature_min || '-'}°C a {batch.storage_temperature_max || '-'}°C
</span>
</div>
)}
{batch.storage_humidity_max && (
<div className="flex items-center gap-1">
<span className="text-[var(--text-tertiary)]">Humedad:</span>
<span className="font-medium text-[var(--text-secondary)]">
{batch.storage_humidity_max}%
</span>
</div>
)}
{batch.shelf_life_days && (
<div className="flex items-center gap-1">
<span className="text-[var(--text-tertiary)]">Vida útil:</span>
<span className="font-medium text-[var(--text-secondary)]">
{batch.shelf_life_days} días
</span>
</div>
)}
</div>
{batch.storage_instructions && (
<div className="mt-2 p-2 bg-[var(--surface-tertiary)] rounded-md">
<div className="text-xs text-[var(--text-secondary)] italic">
"{batch.storage_instructions}"
</div>
</div>
)}
</div>
</div>
</div>
);
})}
</div>
) : (
<div className="text-center py-12 text-[var(--text-secondary)]">
<Package className="w-16 h-16 mx-auto mb-4 opacity-30" />
<h3 className="text-lg font-medium text-[var(--text-primary)] mb-2">
No hay lotes registrados
</h3>
<p className="text-sm mb-6">
Los lotes se crean automáticamente al agregar stock
</p>
{onAddBatch && (
<Button
variant="primary"
onClick={onAddBatch}
className="inline-flex items-center gap-2"
>
<Plus className="w-4 h-4" />
Agregar Primer Lote
</Button>
)}
</div>
);
const sections = [
{
title: 'Lotes de Stock',
icon: Package,
fields: [
{
label: '',
value: batchCards,
span: 2 as const
}
]
}
];
const actions = [];
if (onAddBatch && batches.length > 0) {
actions.push({
label: 'Agregar Lote',
icon: Plus,
variant: 'primary' as const,
onClick: onAddBatch
});
}
return (
<StatusModal
isOpen={isOpen}
onClose={onClose}
mode="view"
title={`Lotes de Stock: ${ingredient.name}`}
subtitle={`${ingredient.category}${batches.length} lotes registrados`}
statusIndicator={statusConfig}
sections={sections}
size="lg"
loading={loading}
showDefaultActions={false}
actions={actions}
/>
);
};
export default BatchModal;

View File

@@ -1,20 +1,20 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { Plus, Package, Calculator, Settings, Thermometer } from 'lucide-react'; import { Plus, Package, Calculator, Settings } from 'lucide-react';
import { StatusModal } from '../../ui/StatusModal/StatusModal'; import { StatusModal } from '../../ui/StatusModal/StatusModal';
import { IngredientCreate, UnitOfMeasure, IngredientCategory, ProductCategory } from '../../../api/types/inventory'; import { IngredientCreate, UnitOfMeasure, IngredientCategory, ProductCategory } from '../../../api/types/inventory';
import { statusColors } from '../../../styles/colors'; import { statusColors } from '../../../styles/colors';
interface CreateItemModalProps { interface CreateIngredientModalProps {
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
onCreateIngredient?: (ingredientData: IngredientCreate) => Promise<void>; onCreateIngredient?: (ingredientData: IngredientCreate) => Promise<void>;
} }
/** /**
* CreateItemModal - Modal for creating a new inventory ingredient * CreateIngredientModal - Modal for creating a new inventory ingredient
* Comprehensive form for adding new items to inventory * Comprehensive form for adding new items to inventory
*/ */
export const CreateItemModal: React.FC<CreateItemModalProps> = ({ export const CreateIngredientModal: React.FC<CreateIngredientModalProps> = ({
isOpen, isOpen,
onClose, onClose,
onCreateIngredient onCreateIngredient
@@ -27,9 +27,6 @@ export const CreateItemModal: React.FC<CreateItemModalProps> = ({
low_stock_threshold: 10, low_stock_threshold: 10,
reorder_point: 20, reorder_point: 20,
max_stock_level: 100, max_stock_level: 100,
shelf_life_days: undefined,
requires_refrigeration: false,
requires_freezing: false,
is_seasonal: false, is_seasonal: false,
supplier_id: '', supplier_id: '',
average_cost: 0, average_cost: 0,
@@ -85,9 +82,7 @@ export const CreateItemModal: React.FC<CreateItemModalProps> = ({
['name', 'description', 'category', 'unit_of_measure'], ['name', 'description', 'category', 'unit_of_measure'],
// Cost and Quantities section // Cost and Quantities section
['average_cost', 'low_stock_threshold', 'reorder_point', 'max_stock_level'], ['average_cost', 'low_stock_threshold', 'reorder_point', 'max_stock_level'],
// Storage Requirements section // Additional Information section (moved up after removing storage section)
['requires_refrigeration', 'requires_freezing', 'shelf_life_days', 'is_seasonal'],
// Additional Information section
['supplier_id', 'notes'] ['supplier_id', 'notes']
]; ];
@@ -268,49 +263,6 @@ export const CreateItemModal: React.FC<CreateItemModalProps> = ({
} }
] ]
}, },
{
title: 'Requisitos de Almacenamiento',
icon: Thermometer,
fields: [
{
label: 'Requiere Refrigeración',
value: formData.requires_refrigeration ? 'Sí' : 'No',
type: 'select' as const,
editable: true,
options: [
{ label: 'No', value: false },
{ label: 'Sí', value: true }
]
},
{
label: 'Requiere Congelación',
value: formData.requires_freezing ? 'Sí' : 'No',
type: 'select' as const,
editable: true,
options: [
{ label: 'No', value: false },
{ label: 'Sí', value: true }
]
},
{
label: 'Vida Útil (días)',
value: formData.shelf_life_days || '',
type: 'number' as const,
editable: true,
placeholder: 'Días de vida útil'
},
{
label: 'Es Estacional',
value: formData.is_seasonal ? 'Sí' : 'No',
type: 'select' as const,
editable: true,
options: [
{ label: 'No', value: false },
{ label: 'Sí', value: true }
]
}
]
},
{ {
title: 'Información Adicional', title: 'Información Adicional',
icon: Settings, icon: Settings,
@@ -352,4 +304,4 @@ export const CreateItemModal: React.FC<CreateItemModalProps> = ({
); );
}; };
export default CreateItemModal; export default CreateIngredientModal;

View File

@@ -1,100 +0,0 @@
import React, { useState } from 'react';
import { Trash2 } from 'lucide-react';
import { Button } from '../../ui';
import { DeleteIngredientModal } from './';
import { useSoftDeleteIngredient, useHardDeleteIngredient } from '../../../api/hooks/inventory';
import { useAuthStore } from '../../../stores/auth.store';
import { IngredientResponse } from '../../../api/types/inventory';
interface DeleteIngredientExampleProps {
ingredient: IngredientResponse;
onDeleteSuccess?: () => void;
}
/**
* Example component showing how to use DeleteIngredientModal
* This can be integrated into inventory cards, tables, or detail pages
*/
export const DeleteIngredientExample: React.FC<DeleteIngredientExampleProps> = ({
ingredient,
onDeleteSuccess
}) => {
const [showDeleteModal, setShowDeleteModal] = useState(false);
const { tenantId } = useAuthStore();
// Hook for soft delete
const softDeleteMutation = useSoftDeleteIngredient({
onSuccess: () => {
setShowDeleteModal(false);
onDeleteSuccess?.();
},
onError: (error) => {
console.error('Soft delete failed:', error);
// Here you could show a toast notification
}
});
// Hook for hard delete
const hardDeleteMutation = useHardDeleteIngredient({
onSuccess: (result) => {
console.log('Hard delete completed:', result);
onDeleteSuccess?.();
// Modal will handle closing itself after showing results
},
onError: (error) => {
console.error('Hard delete failed:', error);
// Here you could show a toast notification
}
});
const handleSoftDelete = async (ingredientId: string) => {
if (!tenantId) {
throw new Error('No tenant ID available');
}
return softDeleteMutation.mutateAsync({
tenantId,
ingredientId
});
};
const handleHardDelete = async (ingredientId: string) => {
if (!tenantId) {
throw new Error('No tenant ID available');
}
return hardDeleteMutation.mutateAsync({
tenantId,
ingredientId
});
};
const isLoading = softDeleteMutation.isPending || hardDeleteMutation.isPending;
return (
<>
{/* Delete Button - This could be in a dropdown menu, action bar, etc. */}
<Button
variant="danger"
size="sm"
onClick={() => setShowDeleteModal(true)}
disabled={isLoading}
>
<Trash2 className="w-4 h-4 mr-2" />
Eliminar
</Button>
{/* Delete Modal */}
<DeleteIngredientModal
isOpen={showDeleteModal}
onClose={() => setShowDeleteModal(false)}
ingredient={ingredient}
onSoftDelete={handleSoftDelete}
onHardDelete={handleHardDelete}
isLoading={isLoading}
/>
</>
);
};
export default DeleteIngredientExample;

View File

@@ -41,8 +41,11 @@ export const DeleteIngredientModal: React.FC<DeleteIngredientModalProps> = ({
if (selectedMode === 'hard') { if (selectedMode === 'hard') {
const result = await onHardDelete(ingredient.id); const result = await onHardDelete(ingredient.id);
setDeletionResult(result); setDeletionResult(result);
// Close modal immediately after successful hard delete
onClose();
} else { } else {
await onSoftDelete(ingredient.id); await onSoftDelete(ingredient.id);
// Close modal immediately after successful soft delete
onClose(); onClose();
} }
} catch (error) { } catch (error) {
@@ -212,16 +215,10 @@ export const DeleteIngredientModal: React.FC<DeleteIngredientModalProps> = ({
return ( return (
<Modal isOpen={isOpen} onClose={handleClose} size="lg"> <Modal isOpen={isOpen} onClose={handleClose} size="lg">
<div className="p-6"> <div className="p-6">
<div className="flex items-center justify-between mb-6"> <div className="mb-6">
<h2 className="text-xl font-semibold text-[var(--text-primary)]"> <h2 className="text-xl font-semibold text-[var(--text-primary)]">
Eliminar Artículo Eliminar Artículo
</h2> </h2>
<button
onClick={handleClose}
className="text-[var(--text-tertiary)] hover:text-[var(--text-secondary)]"
>
<X className="w-5 h-5" />
</button>
</div> </div>
<div className="mb-6"> <div className="mb-6">

View File

@@ -1,297 +0,0 @@
import React, { useState } from 'react';
import { Edit, Package, AlertTriangle, Settings, Thermometer } from 'lucide-react';
import { StatusModal } from '../../ui/StatusModal/StatusModal';
import { IngredientResponse, IngredientUpdate, IngredientCategory, UnitOfMeasure } from '../../../api/types/inventory';
import { statusColors } from '../../../styles/colors';
interface EditItemModalProps {
isOpen: boolean;
onClose: () => void;
ingredient: IngredientResponse;
onUpdateIngredient?: (id: string, updateData: IngredientUpdate) => Promise<void>;
}
/**
* EditItemModal - Focused modal for editing ingredient details
* Organized form for updating ingredient properties
*/
export const EditItemModal: React.FC<EditItemModalProps> = ({
isOpen,
onClose,
ingredient,
onUpdateIngredient
}) => {
const [formData, setFormData] = useState<IngredientUpdate>({
name: ingredient.name,
description: ingredient.description || '',
category: ingredient.category,
brand: ingredient.brand || '',
unit_of_measure: ingredient.unit_of_measure,
average_cost: ingredient.average_cost || 0,
low_stock_threshold: ingredient.low_stock_threshold,
reorder_point: ingredient.reorder_point,
max_stock_level: ingredient.max_stock_level || undefined,
requires_refrigeration: ingredient.requires_refrigeration,
requires_freezing: ingredient.requires_freezing,
shelf_life_days: ingredient.shelf_life_days || undefined,
storage_instructions: ingredient.storage_instructions || '',
is_active: ingredient.is_active,
notes: ingredient.notes || ''
});
const [loading, setLoading] = useState(false);
const [mode, setMode] = useState<'overview' | 'edit'>('edit');
const handleFieldChange = (sectionIndex: number, fieldIndex: number, value: string | number | boolean) => {
// Map field positions to form data fields
const fieldMappings = [
// Basic Information section
['name', 'description', 'category', 'brand'],
// Measurements section
['unit_of_measure', 'average_cost', 'low_stock_threshold', 'reorder_point', 'max_stock_level'],
// Storage Requirements section
['requires_refrigeration', 'requires_freezing', 'shelf_life_days', 'storage_instructions'],
// Additional Settings section
['is_active', 'notes']
];
const fieldName = fieldMappings[sectionIndex]?.[fieldIndex] as keyof IngredientUpdate;
if (fieldName) {
setFormData(prev => ({
...prev,
[fieldName]: value
}));
}
};
const handleSave = async () => {
if (!formData.name?.trim()) {
alert('El nombre es requerido');
return;
}
if (!formData.low_stock_threshold || formData.low_stock_threshold < 0) {
alert('El umbral de stock bajo debe ser un número positivo');
return;
}
if (!formData.reorder_point || formData.reorder_point < 0) {
alert('El punto de reorden debe ser un número positivo');
return;
}
setLoading(true);
try {
if (onUpdateIngredient) {
await onUpdateIngredient(ingredient.id, formData);
}
onClose();
} catch (error) {
console.error('Error updating ingredient:', error);
alert('Error al actualizar el artículo. Por favor, intenta de nuevo.');
} finally {
setLoading(false);
}
};
const statusConfig = {
color: statusColors.inProgress.primary,
text: 'Editar Artículo',
icon: Edit
};
const categoryOptions = Object.values(IngredientCategory).map(cat => ({
label: cat.charAt(0).toUpperCase() + cat.slice(1),
value: cat
}));
const unitOptions = Object.values(UnitOfMeasure).map(unit => ({
label: unit,
value: unit
}));
const sections = [
{
title: 'Información Básica',
icon: Package,
fields: [
{
label: 'Nombre',
value: formData.name || '',
type: 'text' as const,
editable: true,
required: true,
placeholder: 'Nombre del artículo'
},
{
label: 'Descripción',
value: formData.description || '',
type: 'text' as const,
editable: true,
placeholder: 'Descripción del artículo'
},
{
label: 'Categoría',
value: formData.category || '',
type: 'select' as const,
editable: true,
required: true,
options: categoryOptions
},
{
label: 'Marca',
value: formData.brand || '',
type: 'text' as const,
editable: true,
placeholder: 'Marca del producto'
}
]
},
{
title: 'Medidas y Costos',
icon: Settings,
fields: [
{
label: 'Unidad de Medida',
value: formData.unit_of_measure || '',
type: 'select' as const,
editable: true,
required: true,
options: unitOptions
},
{
label: 'Costo Promedio',
value: formData.average_cost || 0,
type: 'currency' as const,
editable: true,
placeholder: '0.00'
},
{
label: 'Umbral Stock Bajo',
value: formData.low_stock_threshold || 0,
type: 'number' as const,
editable: true,
required: true,
placeholder: 'Cantidad mínima'
},
{
label: 'Punto de Reorden',
value: formData.reorder_point || 0,
type: 'number' as const,
editable: true,
required: true,
placeholder: 'Cuando reordenar'
},
{
label: 'Stock Máximo',
value: formData.max_stock_level || 0,
type: 'number' as const,
editable: true,
placeholder: 'Stock máximo (opcional)'
}
]
},
{
title: 'Requisitos de Almacenamiento',
icon: Thermometer,
fields: [
{
label: 'Requiere Refrigeración',
value: formData.requires_refrigeration ? 'Sí' : 'No',
type: 'select' as const,
editable: true,
options: [
{ label: 'No', value: false },
{ label: 'Sí', value: true }
]
},
{
label: 'Requiere Congelación',
value: formData.requires_freezing ? 'Sí' : 'No',
type: 'select' as const,
editable: true,
options: [
{ label: 'No', value: false },
{ label: 'Sí', value: true }
]
},
{
label: 'Vida Útil (días)',
value: formData.shelf_life_days || 0,
type: 'number' as const,
editable: true,
placeholder: 'Días de duración'
},
{
label: 'Instrucciones de Almacenamiento',
value: formData.storage_instructions || '',
type: 'text' as const,
editable: true,
placeholder: 'Instrucciones especiales...',
span: 2 as const
}
]
},
{
title: 'Configuración Adicional',
icon: AlertTriangle,
fields: [
{
label: 'Estado',
value: formData.is_active ? 'Activo' : 'Inactivo',
type: 'select' as const,
editable: true,
options: [
{ label: 'Activo', value: true },
{ label: 'Inactivo', value: false }
]
},
{
label: 'Notas',
value: formData.notes || '',
type: 'text' as const,
editable: true,
placeholder: 'Notas adicionales...',
span: 2 as const
}
]
}
];
const actions = [
{
label: 'Cancelar',
variant: 'outline' as const,
onClick: onClose,
disabled: loading
},
{
label: 'Guardar Cambios',
variant: 'primary' as const,
onClick: handleSave,
disabled: loading || !formData.name?.trim(),
loading
}
];
return (
<StatusModal
isOpen={isOpen}
onClose={onClose}
mode={mode}
onModeChange={setMode}
title={`Editar: ${ingredient.name}`}
subtitle={`${ingredient.category} • ID: ${ingredient.id.slice(0, 8)}...`}
statusIndicator={statusConfig}
sections={sections}
actions={actions}
onFieldChange={handleFieldChange}
onSave={handleSave}
size="xl"
loading={loading}
showDefaultActions={false}
/>
);
};
export default EditItemModal;

View File

@@ -1,244 +0,0 @@
import React from 'react';
import { Clock, TrendingUp, TrendingDown, Package, AlertCircle, RotateCcw } from 'lucide-react';
import { StatusModal } from '../../ui/StatusModal/StatusModal';
import { IngredientResponse, StockMovementResponse } from '../../../api/types/inventory';
import { formatters } from '../../ui/Stats/StatsPresets';
import { statusColors } from '../../../styles/colors';
interface HistoryModalProps {
isOpen: boolean;
onClose: () => void;
ingredient: IngredientResponse;
movements: StockMovementResponse[];
loading?: boolean;
}
/**
* HistoryModal - Focused modal for viewing stock movement history
* Clean, scannable list of recent movements
*/
export const HistoryModal: React.FC<HistoryModalProps> = ({
isOpen,
onClose,
ingredient,
movements = [],
loading = false
}) => {
// Group movements by type for better organization
const groupedMovements = movements.reduce((acc, movement) => {
const type = movement.movement_type;
if (!acc[type]) acc[type] = [];
acc[type].push(movement);
return acc;
}, {} as Record<string, StockMovementResponse[]>);
// Get movement type display info
const getMovementTypeInfo = (type: string) => {
switch (type) {
case 'purchase':
return { label: 'Compra', icon: TrendingUp, color: statusColors.normal.primary };
case 'production_use':
return { label: 'Uso en Producción', icon: TrendingDown, color: statusColors.pending.primary };
case 'adjustment':
return { label: 'Ajuste', icon: Package, color: statusColors.inProgress.primary };
case 'waste':
return { label: 'Merma', icon: AlertCircle, color: statusColors.cancelled.primary };
case 'transfer':
return { label: 'Transferencia', icon: RotateCcw, color: statusColors.bread.primary };
case 'return':
return { label: 'Devolución', icon: RotateCcw, color: statusColors.inTransit.primary };
case 'initial_stock':
return { label: 'Stock Inicial', icon: Package, color: statusColors.completed.primary };
case 'transformation':
return { label: 'Transformación', icon: RotateCcw, color: statusColors.pastry.primary };
default:
return { label: type, icon: Package, color: statusColors.other.primary };
}
};
// Format movement for display
const formatMovement = (movement: StockMovementResponse) => {
const typeInfo = getMovementTypeInfo(movement.movement_type);
const date = new Date(movement.movement_date).toLocaleDateString('es-ES');
const time = new Date(movement.movement_date).toLocaleTimeString('es-ES', {
hour: '2-digit',
minute: '2-digit'
});
const quantity = Number(movement.quantity);
const isPositive = quantity > 0;
const quantityText = `${isPositive ? '+' : ''}${quantity} ${ingredient.unit_of_measure}`;
return {
id: movement.id,
type: typeInfo.label,
icon: typeInfo.icon,
color: typeInfo.color,
quantity: quantityText,
isPositive,
date: `${date} ${time}`,
reference: movement.reference_number || '-',
notes: movement.notes || '-',
cost: movement.total_cost ? formatters.currency(movement.total_cost) : '-',
quantityBefore: movement.quantity_before || 0,
quantityAfter: movement.quantity_after || 0
};
};
const recentMovements = movements
.slice(0, 20) // Show last 20 movements
.map(formatMovement);
const statusConfig = {
color: statusColors.inProgress.primary,
text: `${movements.length} movimientos`,
icon: Clock
};
// Create a visual movement list
const movementsList = recentMovements.length > 0 ? (
<div className="space-y-3 max-h-96 overflow-y-auto">
{recentMovements.map((movement) => {
const MovementIcon = movement.icon;
return (
<div
key={movement.id}
className="flex items-center gap-3 p-3 bg-[var(--surface-secondary)] rounded-lg hover:bg-[var(--surface-tertiary)] transition-colors"
>
{/* Icon and type */}
<div
className="flex-shrink-0 p-2 rounded-lg"
style={{ backgroundColor: `${movement.color}15` }}
>
<MovementIcon
className="w-4 h-4"
style={{ color: movement.color }}
/>
</div>
{/* Main content */}
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between">
<span className="font-medium text-[var(--text-primary)]">
{movement.type}
</span>
<span
className="font-bold"
style={{
color: movement.isPositive
? statusColors.normal.primary
: statusColors.pending.primary
}}
>
{movement.quantity}
</span>
</div>
<div className="flex items-center justify-between text-sm text-[var(--text-secondary)] mt-1">
<span>{movement.date}</span>
<span>{movement.cost}</span>
</div>
{movement.reference !== '-' && (
<div className="text-xs text-[var(--text-tertiary)] mt-1">
Ref: {movement.reference}
</div>
)}
{movement.notes !== '-' && (
<div className="text-xs text-[var(--text-secondary)] mt-1 truncate">
{movement.notes}
</div>
)}
</div>
{/* Stock levels */}
<div className="text-right text-sm">
<div className="text-[var(--text-tertiary)]">
{movement.quantityBefore} {movement.quantityAfter}
</div>
<div className="text-xs text-[var(--text-tertiary)]">
{ingredient.unit_of_measure}
</div>
</div>
</div>
);
})}
</div>
) : (
<div className="text-center py-8 text-[var(--text-secondary)]">
<Clock className="w-12 h-12 mx-auto mb-3 opacity-50" />
<p>No hay movimientos de stock registrados</p>
</div>
);
const sections = [
{
title: 'Historial de Movimientos',
icon: Clock,
fields: [
{
label: '',
value: movementsList,
span: 2 as const
}
]
}
];
// Add summary if we have movements
if (movements.length > 0) {
const totalIn = movements
.filter(m => Number(m.quantity) > 0)
.reduce((sum, m) => sum + Number(m.quantity), 0);
const totalOut = movements
.filter(m => Number(m.quantity) < 0)
.reduce((sum, m) => sum + Math.abs(Number(m.quantity)), 0);
const totalValue = movements
.reduce((sum, m) => sum + (Number(m.total_cost) || 0), 0);
sections.unshift({
title: 'Resumen de Actividad',
icon: Package,
fields: [
{
label: 'Total Entradas',
value: `${totalIn} ${ingredient.unit_of_measure}`,
highlight: true,
span: 1 as const
},
{
label: 'Total Salidas',
value: `${totalOut} ${ingredient.unit_of_measure}`,
span: 1 as const
},
{
label: 'Valor Total Movimientos',
value: formatters.currency(Math.abs(totalValue)),
type: 'currency' as const,
span: 2 as const
}
]
});
}
return (
<StatusModal
isOpen={isOpen}
onClose={onClose}
mode="view"
title={`Historial: ${ingredient.name}`}
subtitle={`${ingredient.category} • Últimos ${Math.min(movements.length, 20)} movimientos`}
statusIndicator={statusConfig}
sections={sections}
size="lg"
loading={loading}
showDefaultActions={false}
/>
);
};
export default HistoryModal;

View File

@@ -1,166 +0,0 @@
import React from 'react';
import { Package, AlertTriangle, CheckCircle, Clock, Euro } from 'lucide-react';
import { StatusModal } from '../../ui/StatusModal/StatusModal';
import { IngredientResponse } from '../../../api/types/inventory';
import { formatters } from '../../ui/Stats/StatsPresets';
import { statusColors } from '../../../styles/colors';
interface QuickViewModalProps {
isOpen: boolean;
onClose: () => void;
ingredient: IngredientResponse;
}
/**
* QuickViewModal - Focused modal for viewing essential stock information
* Shows only the most important data users need for quick decisions
*/
export const QuickViewModal: React.FC<QuickViewModalProps> = ({
isOpen,
onClose,
ingredient
}) => {
// Safe number conversions
const currentStock = Number(ingredient.current_stock) || 0;
const maxStock = Number(ingredient.max_stock_level) || 0;
const averageCost = Number(ingredient.average_cost) || 0;
const lowThreshold = Number(ingredient.low_stock_threshold) || 0;
const reorderPoint = Number(ingredient.reorder_point) || 0;
// Calculate derived values
const totalValue = currentStock * averageCost;
const stockPercentage = maxStock > 0 ? Math.round((currentStock / maxStock) * 100) : 0;
const daysOfStock = currentStock > 0 ? Math.round(currentStock / (averageCost || 1)) : 0;
// Status configuration
const getStatusConfig = () => {
if (currentStock === 0) {
return {
color: statusColors.out.primary,
text: 'Sin Stock',
icon: AlertTriangle,
isCritical: true
};
}
if (currentStock <= lowThreshold) {
return {
color: statusColors.low.primary,
text: 'Stock Bajo',
icon: AlertTriangle,
isHighlight: true
};
}
return {
color: statusColors.normal.primary,
text: 'Stock Normal',
icon: CheckCircle
};
};
const statusConfig = getStatusConfig();
const sections = [
{
title: 'Estado del Stock',
icon: Package,
fields: [
{
label: 'Cantidad Actual',
value: `${currentStock} ${ingredient.unit_of_measure}`,
highlight: true,
span: 1 as const
},
{
label: 'Valor Total',
value: formatters.currency(totalValue),
type: 'currency' as const,
highlight: true,
span: 1 as const
},
{
label: 'Nivel de Stock',
value: maxStock > 0 ? `${stockPercentage}%` : 'N/A',
span: 1 as const
},
{
label: 'Días Estimados',
value: `~${daysOfStock} días`,
span: 1 as const
}
]
},
{
title: 'Umbrales de Control',
icon: AlertTriangle,
fields: [
{
label: 'Punto de Reorden',
value: `${reorderPoint} ${ingredient.unit_of_measure}`,
span: 1 as const
},
{
label: 'Stock Mínimo',
value: `${lowThreshold} ${ingredient.unit_of_measure}`,
span: 1 as const
},
{
label: 'Stock Máximo',
value: maxStock > 0 ? `${maxStock} ${ingredient.unit_of_measure}` : 'No definido',
span: 1 as const
},
{
label: 'Costo Promedio',
value: formatters.currency(averageCost),
type: 'currency' as const,
span: 1 as const
}
]
}
];
// Add storage requirements if available
if (ingredient.requires_refrigeration || ingredient.requires_freezing || ingredient.shelf_life_days) {
sections.push({
title: 'Requisitos de Almacenamiento',
icon: Clock,
fields: [
...(ingredient.shelf_life_days ? [{
label: 'Vida Útil',
value: `${ingredient.shelf_life_days} días`,
span: 1 as const
}] : []),
...(ingredient.requires_refrigeration ? [{
label: 'Refrigeración',
value: 'Requerida',
span: 1 as const
}] : []),
...(ingredient.requires_freezing ? [{
label: 'Congelación',
value: 'Requerida',
span: 1 as const
}] : []),
...(ingredient.storage_instructions ? [{
label: 'Instrucciones',
value: ingredient.storage_instructions,
span: 2 as const
}] : [])
]
});
}
return (
<StatusModal
isOpen={isOpen}
onClose={onClose}
mode="view"
title={ingredient.name}
subtitle={`${ingredient.category}${ingredient.description || 'Sin descripción'}`}
statusIndicator={statusConfig}
sections={sections}
size="md"
showDefaultActions={false}
/>
);
};
export default QuickViewModal;

View File

@@ -0,0 +1,303 @@
import React, { useState } from 'react';
import { Package, AlertTriangle, CheckCircle, Clock, Euro, Edit, Info, Thermometer, Calendar, Tag, Save, X, TrendingUp } from 'lucide-react';
import { StatusModal } from '../../ui/StatusModal/StatusModal';
import { IngredientResponse } from '../../../api/types/inventory';
import { formatters } from '../../ui/Stats/StatsPresets';
import { statusColors } from '../../../styles/colors';
interface ShowInfoModalProps {
isOpen: boolean;
onClose: () => void;
ingredient: IngredientResponse;
onSave?: (updatedData: Partial<IngredientResponse>) => Promise<void>;
}
/**
* ShowInfoModal - Complete item details modal
* Shows ALL item information excluding stock, lots, and movements data
* Includes edit functionality for item properties
*/
export const ShowInfoModal: React.FC<ShowInfoModalProps> = ({
isOpen,
onClose,
ingredient,
onSave
}) => {
const [isEditing, setIsEditing] = useState(false);
const [editData, setEditData] = useState<Partial<IngredientResponse>>({});
const handleEdit = () => {
setIsEditing(true);
setEditData(ingredient);
};
const handleCancel = () => {
setIsEditing(false);
setEditData({});
};
const handleSave = async () => {
if (onSave) {
await onSave(editData);
setIsEditing(false);
setEditData({});
}
};
// Status configuration based on item status (not stock)
const statusConfig = {
color: ingredient.is_active ? statusColors.normal.primary : statusColors.cancelled.primary,
text: ingredient.is_active ? 'Activo' : 'Inactivo',
icon: ingredient.is_active ? CheckCircle : AlertTriangle,
isCritical: !ingredient.is_active
};
const currentData = isEditing ? editData : ingredient;
const sections = [
{
title: 'Información Básica',
icon: Info,
fields: [
{
label: 'Nombre',
value: currentData.name || '',
highlight: true,
span: 2 as const,
editable: true,
required: true
},
{
label: 'Descripción',
value: currentData.description || '',
span: 2 as const,
editable: true,
placeholder: 'Descripción del producto'
},
{
label: 'Categoría',
value: currentData.category || '',
span: 1 as const,
editable: true,
required: true
},
{
label: 'Subcategoría',
value: currentData.subcategory || '',
span: 1 as const,
editable: true,
placeholder: 'Subcategoría'
},
{
label: 'Marca',
value: currentData.brand || '',
span: 1 as const,
editable: true,
placeholder: 'Marca del producto'
},
{
label: 'Tipo de Producto',
value: currentData.product_type || '',
span: 1 as const,
editable: true,
placeholder: 'Tipo de producto'
}
]
},
{
title: 'Especificaciones',
icon: Package,
fields: [
{
label: 'Unidad de Medida',
value: currentData.unit_of_measure || '',
span: 1 as const,
editable: true,
required: true,
placeholder: 'kg, litros, unidades, etc.'
},
{
label: 'Tamaño del Paquete',
value: currentData.package_size || '',
span: 1 as const,
editable: true,
type: 'number' as const,
placeholder: 'Tamaño del paquete'
},
{
label: 'Es Perecedero',
value: currentData.is_perishable ? 'Sí' : 'No',
span: 1 as const,
editable: true,
type: 'select' as const,
options: [
{ label: 'Sí', value: 'true' },
{ label: 'No', value: 'false' }
]
},
{
label: 'Es de Temporada',
value: currentData.is_seasonal ? 'Sí' : 'No',
span: 1 as const,
editable: true,
type: 'select' as const,
options: [
{ label: 'Sí', value: 'true' },
{ label: 'No', value: 'false' }
]
}
]
},
{
title: 'Costos y Precios',
icon: Euro,
fields: [
{
label: 'Costo Promedio',
value: Number(currentData.average_cost) || 0,
type: 'currency' as const,
span: 1 as const,
editable: true,
placeholder: '0.00'
},
{
label: 'Último Precio de Compra',
value: Number(currentData.last_purchase_price) || 0,
type: 'currency' as const,
span: 1 as const,
editable: true,
placeholder: '0.00'
},
{
label: 'Costo Estándar',
value: Number(currentData.standard_cost) || 0,
type: 'currency' as const,
span: 2 as const,
editable: true,
placeholder: '0.00'
}
]
},
{
title: 'Parámetros de Inventario',
icon: TrendingUp,
fields: [
{
label: 'Umbral Stock Bajo',
value: currentData.low_stock_threshold || 0,
span: 1 as const,
editable: true,
type: 'number' as const,
placeholder: 'Cantidad mínima antes de alerta'
},
{
label: 'Punto de Reorden',
value: currentData.reorder_point || 0,
span: 1 as const,
editable: true,
type: 'number' as const,
placeholder: 'Punto para reordenar'
},
{
label: 'Cantidad de Reorden',
value: currentData.reorder_quantity || 0,
span: 1 as const,
editable: true,
type: 'number' as const,
placeholder: 'Cantidad a reordenar'
},
{
label: 'Stock Máximo',
value: currentData.max_stock_level || '',
span: 1 as const,
editable: true,
type: 'number' as const,
placeholder: 'Cantidad máxima permitida'
}
]
}
];
// Actions based on edit mode
const actions = [];
if (isEditing) {
actions.push(
{
label: 'Cancelar',
icon: X,
variant: 'outline' as const,
onClick: handleCancel
},
{
label: 'Guardar',
icon: Save,
variant: 'primary' as const,
onClick: handleSave
}
);
} else if (onSave) {
actions.push({
label: 'Editar',
icon: Edit,
variant: 'primary' as const,
onClick: handleEdit
});
}
// Handle field changes
const handleFieldChange = (sectionIndex: number, fieldIndex: number, value: string | number) => {
if (!isEditing) return;
// Map field indices to ingredient properties
const fieldMappings = [
// Section 0: Información Básica
['name', 'description', 'category', 'subcategory', 'brand', 'product_type'],
// Section 1: Especificaciones
['unit_of_measure', 'package_size', 'is_perishable', 'is_seasonal'],
// Section 2: Costos y Precios
['average_cost', 'last_purchase_price', 'standard_cost'],
// Section 3: Parámetros de Inventario
['low_stock_threshold', 'reorder_point', 'reorder_quantity', 'max_stock_level']
];
const fieldName = fieldMappings[sectionIndex]?.[fieldIndex];
if (!fieldName) return;
let processedValue = value;
// Handle boolean fields
if (fieldName === 'is_perishable' || fieldName === 'is_seasonal') {
processedValue = value === 'true';
}
// Handle numeric fields
if (fieldName === 'package_size' || fieldName.includes('cost') || fieldName.includes('price') ||
fieldName.includes('threshold') || fieldName.includes('point') || fieldName.includes('quantity') ||
fieldName.includes('level')) {
processedValue = Number(value) || 0;
}
setEditData(prev => ({
...prev,
[fieldName]: processedValue
}));
};
return (
<StatusModal
isOpen={isOpen}
onClose={onClose}
mode={isEditing ? "edit" : "view"}
title={`${isEditing ? 'Editar' : 'Detalles'}: ${ingredient.name}`}
subtitle={`${ingredient.category} • Información del artículo`}
statusIndicator={statusConfig}
sections={sections}
size="lg"
showDefaultActions={false}
actions={actions}
onFieldChange={handleFieldChange}
/>
);
};
export default ShowInfoModal;

View File

@@ -0,0 +1,232 @@
import React from 'react';
import { Clock, TrendingDown, Package, AlertCircle, RotateCcw, X } from 'lucide-react';
import { StatusModal } from '../../ui/StatusModal/StatusModal';
import { IngredientResponse, StockMovementResponse } from '../../../api/types/inventory';
import { formatters } from '../../ui/Stats/StatsPresets';
import { statusColors } from '../../../styles/colors';
interface StockHistoryModalProps {
isOpen: boolean;
onClose: () => void;
ingredient: IngredientResponse;
movements: StockMovementResponse[];
loading?: boolean;
}
/**
* StockHistoryModal - Dedicated modal for stock movement history
* Shows only the movements list in a clean, focused interface
*/
export const StockHistoryModal: React.FC<StockHistoryModalProps> = ({
isOpen,
onClose,
ingredient,
movements = [],
loading = false
}) => {
// Get movement type display info
const getMovementTypeInfo = (type: string, quantity: number) => {
const isPositive = quantity > 0;
const absQuantity = Math.abs(quantity);
switch (type) {
case 'PURCHASE':
return {
type: 'Compra',
icon: Package,
color: statusColors.completed.primary,
isPositive: true,
quantity: `+${absQuantity}`
};
case 'PRODUCTION_USE':
return {
type: 'Uso en Producción',
icon: TrendingDown,
color: statusColors.pending.primary,
isPositive: false,
quantity: `-${absQuantity}`
};
case 'ADJUSTMENT':
return {
type: 'Ajuste',
icon: AlertCircle,
color: statusColors.inProgress.primary,
isPositive,
quantity: isPositive ? `+${absQuantity}` : `-${absQuantity}`
};
case 'WASTE':
return {
type: 'Desperdicio',
icon: X,
color: statusColors.out.primary,
isPositive: false,
quantity: `-${absQuantity}`
};
case 'TRANSFORMATION':
return {
type: 'Transformación',
icon: RotateCcw,
color: statusColors.low.primary,
isPositive,
quantity: isPositive ? `+${absQuantity}` : `-${absQuantity}`
};
case 'INITIAL_STOCK':
return {
type: 'Stock Inicial',
icon: Package,
color: statusColors.normal.primary,
isPositive: true,
quantity: `+${absQuantity}`
};
default:
return {
type: 'Otro',
icon: Package,
color: statusColors.other.primary,
isPositive,
quantity: isPositive ? `+${absQuantity}` : `-${absQuantity}`
};
}
};
// Process movements for display
const recentMovements = movements.slice(0, 20).map(movement => {
const movementInfo = getMovementTypeInfo(movement.movement_type, Number(movement.quantity));
return {
id: movement.id,
...movementInfo,
date: movement.movement_date ? new Date(movement.movement_date).toLocaleDateString('es-ES') : 'Sin fecha',
cost: movement.unit_cost ? formatters.currency(Number(movement.unit_cost)) : '-',
reference: movement.reference_number || '-',
notes: movement.notes || '-',
quantityBefore: Number(movement.quantity_before) || 0,
quantityAfter: Number(movement.quantity_after) || 0
};
});
const statusConfig = {
color: statusColors.inProgress.primary,
text: `${movements.length} movimientos`,
icon: Clock
};
// Create movements list display
const movementsList = recentMovements.length > 0 ? (
<div className="space-y-3 max-h-96 overflow-y-auto">
{recentMovements.map((movement) => {
const MovementIcon = movement.icon;
return (
<div
key={movement.id}
className="flex items-center gap-3 p-4 bg-[var(--surface-secondary)] rounded-lg hover:bg-[var(--surface-tertiary)] transition-colors border border-[var(--border-primary)]"
>
{/* Icon and type */}
<div
className="flex-shrink-0 p-2 rounded-lg"
style={{ backgroundColor: `${movement.color}15` }}
>
<MovementIcon
className="w-5 h-5"
style={{ color: movement.color }}
/>
</div>
{/* Main content */}
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between">
<span className="font-medium text-[var(--text-primary)]">
{movement.type}
</span>
<span
className="font-bold text-lg"
style={{
color: movement.isPositive
? statusColors.completed.primary
: statusColors.out.primary
}}
>
{movement.quantity}
</span>
</div>
<div className="flex items-center justify-between text-sm text-[var(--text-secondary)] mt-1">
<span>{movement.date}</span>
<span>{movement.cost}</span>
</div>
{movement.reference !== '-' && (
<div className="text-xs text-[var(--text-tertiary)] mt-1">
Ref: {movement.reference}
</div>
)}
{movement.notes !== '-' && (
<div className="text-xs text-[var(--text-secondary)] mt-1 truncate">
{movement.notes}
</div>
)}
</div>
{/* Stock levels */}
<div className="text-right text-sm">
<div className="text-[var(--text-tertiary)]">
{movement.quantityBefore} {movement.quantityAfter}
</div>
<div className="text-xs text-[var(--text-tertiary)]">
{ingredient.unit_of_measure}
</div>
</div>
</div>
);
})}
{movements.length > 20 && (
<div className="text-center text-sm text-[var(--text-secondary)] mt-4 p-3 bg-[var(--surface-secondary)] rounded-lg">
Y {movements.length - 20} movimientos más...
</div>
)}
</div>
) : (
<div className="text-center py-12 text-[var(--text-secondary)]">
<Clock className="w-16 h-16 mx-auto mb-4 opacity-30" />
<h3 className="text-lg font-medium text-[var(--text-primary)] mb-2">
No hay movimientos registrados
</h3>
<p className="text-sm">
Los movimientos de stock aparecerán aquí cuando se agregue o use inventario
</p>
</div>
);
const sections = [
{
title: 'Historial de Movimientos',
icon: Clock,
fields: [
{
label: '',
value: movementsList,
span: 2 as const
}
]
}
];
return (
<StatusModal
isOpen={isOpen}
onClose={onClose}
mode="view"
title={`Historial de Stock: ${ingredient.name}`}
subtitle={`${ingredient.category}${movements.length} movimientos registrados`}
statusIndicator={statusConfig}
sections={sections}
size="lg"
loading={loading}
showDefaultActions={false}
/>
);
};
export default StockHistoryModal;

View File

@@ -1,311 +0,0 @@
import React from 'react';
import { Package, Calendar, AlertTriangle, CheckCircle, Clock } from 'lucide-react';
import { StatusModal } from '../../ui/StatusModal/StatusModal';
import { IngredientResponse, StockResponse } from '../../../api/types/inventory';
import { formatters } from '../../ui/Stats/StatsPresets';
import { statusColors } from '../../../styles/colors';
interface StockLotsModalProps {
isOpen: boolean;
onClose: () => void;
ingredient: IngredientResponse;
stockLots: StockResponse[];
loading?: boolean;
}
/**
* StockLotsModal - Focused modal for viewing individual stock lots/batches
* Shows detailed breakdown of all stock batches with expiration, quantities, etc.
*/
export const StockLotsModal: React.FC<StockLotsModalProps> = ({
isOpen,
onClose,
ingredient,
stockLots = [],
loading = false
}) => {
// Sort stock lots by expiration date (earliest first, then by batch number)
// Use current_quantity from API response
const sortedLots = stockLots
.filter(lot => (lot.current_quantity || lot.quantity || 0) > 0) // Handle both API variations
.sort((a, b) => {
if (a.expiration_date && b.expiration_date) {
return new Date(a.expiration_date).getTime() - new Date(b.expiration_date).getTime();
}
if (a.expiration_date && !b.expiration_date) return -1;
if (!a.expiration_date && b.expiration_date) return 1;
return (a.batch_number || '').localeCompare(b.batch_number || '');
});
// Get lot status info using global color system
const getLotStatus = (lot: StockResponse) => {
if (!lot.expiration_date) {
return {
label: 'Sin Vencimiento',
color: statusColors.other.primary,
icon: Package,
isCritical: false
};
}
const today = new Date();
const expirationDate = new Date(lot.expiration_date);
const daysUntilExpiry = Math.ceil((expirationDate.getTime() - today.getTime()) / (1000 * 60 * 60 * 24));
if (daysUntilExpiry < 0) {
return {
label: 'Vencido',
color: statusColors.expired.primary,
icon: AlertTriangle,
isCritical: true
};
} else if (daysUntilExpiry <= 3) {
return {
label: 'Vence Pronto',
color: statusColors.low.primary,
icon: AlertTriangle,
isCritical: true
};
} else if (daysUntilExpiry <= 7) {
return {
label: 'Por Vencer',
color: statusColors.pending.primary,
icon: Clock,
isCritical: false
};
} else {
return {
label: 'Fresco',
color: statusColors.normal.primary,
icon: CheckCircle,
isCritical: false
};
}
};
// Format lot for display
const formatLot = (lot: StockResponse, index: number) => {
const status = getLotStatus(lot);
const expirationDate = lot.expiration_date ?
new Date(lot.expiration_date).toLocaleDateString('es-ES') : 'N/A';
const daysUntilExpiry = lot.expiration_date ?
Math.ceil((new Date(lot.expiration_date).getTime() - new Date().getTime()) / (1000 * 60 * 60 * 24)) : null;
const StatusIcon = status.icon;
return (
<div
key={lot.id}
className="flex items-center gap-3 p-4 rounded-lg border border-[var(--border-primary)] bg-[var(--surface-secondary)] hover:bg-[var(--surface-tertiary)] transition-colors"
style={{
borderColor: status.isCritical ? `${status.color}40` : undefined,
backgroundColor: status.isCritical ? `${status.color}08` : undefined
}}
>
{/* Status indicator */}
<div
className="flex-shrink-0 p-2 rounded-lg"
style={{ backgroundColor: `${status.color}15` }}
>
<StatusIcon
className="w-5 h-5"
style={{ color: status.color }}
/>
</div>
{/* Lot information */}
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between mb-2">
<div>
<div className="font-medium text-[var(--text-primary)]">
Lote #{index + 1} {lot.batch_number && `(${lot.batch_number})`}
</div>
<div
className="text-sm font-medium"
style={{ color: status.color }}
>
{status.label} {daysUntilExpiry !== null && daysUntilExpiry >= 0 && `(${daysUntilExpiry} días)`}
</div>
</div>
<div className="text-right">
<div className="text-lg font-bold text-[var(--text-primary)]">
{lot.current_quantity || lot.quantity} {ingredient.unit_of_measure}
</div>
{lot.available_quantity !== (lot.current_quantity || lot.quantity) && (
<div className="text-xs text-[var(--text-secondary)]">
Disponible: {lot.available_quantity}
</div>
)}
</div>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 text-sm">
<div>
<span className="text-[var(--text-secondary)]">Vencimiento:</span>
<div className="font-medium">{expirationDate}</div>
</div>
<div>
<span className="text-[var(--text-secondary)]">Precio/unidad:</span>
<div className="font-medium">{formatters.currency(lot.unit_cost || lot.unit_price || 0)}</div>
</div>
<div>
<span className="text-[var(--text-secondary)]">Valor total:</span>
<div className="font-medium">{formatters.currency(lot.total_cost || lot.total_value || 0)}</div>
</div>
<div>
<span className="text-[var(--text-secondary)]">Etapa:</span>
<div className="font-medium capitalize">{lot.production_stage.replace('_', ' ')}</div>
</div>
</div>
{lot.notes && (
<div className="mt-2 text-xs text-[var(--text-secondary)]">
📝 {lot.notes}
</div>
)}
</div>
</div>
);
};
const statusConfig = {
color: statusColors.inProgress.primary,
text: loading
? 'Cargando...'
: stockLots.length === 0
? 'Sin datos de lotes'
: `${sortedLots.length} de ${stockLots.length} lotes`,
icon: Package
};
// Create the lots list
const lotsDisplay = loading ? (
<div className="text-center py-8 text-[var(--text-secondary)]">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-[var(--color-primary)] mx-auto mb-3"></div>
<p>Cargando lotes...</p>
</div>
) : stockLots.length === 0 ? (
<div className="text-center py-8 text-[var(--text-secondary)]">
<Package className="w-12 h-12 mx-auto mb-3 opacity-50" />
<p>No hay datos de lotes para este ingrediente</p>
<p className="text-xs mt-2">Es posible que no se hayan registrado lotes individuales</p>
</div>
) : sortedLots.length === 0 ? (
<div className="text-center py-8 text-[var(--text-secondary)]">
<Package className="w-12 h-12 mx-auto mb-3 opacity-50" />
<p>No hay lotes disponibles con stock</p>
<p className="text-xs mt-2">Total de lotes registrados: {stockLots.length}</p>
<div className="text-xs mt-2 text-left max-w-sm mx-auto">
<div>Lotes filtrados por:</div>
<ul className="list-disc list-inside">
<li>Cantidad &gt; 0</li>
</ul>
</div>
</div>
) : (
<div className="space-y-3 max-h-96 overflow-y-auto">
{sortedLots.map((lot, index) => formatLot(lot, index))}
</div>
);
const sections = [
{
title: stockLots.length === 0 ? 'Información de Stock' : 'Lotes de Stock Disponibles',
icon: Package,
fields: [
{
label: '',
value: stockLots.length === 0 ? (
<div className="space-y-4">
<div className="text-center py-6 text-[var(--text-secondary)]">
<Package className="w-10 h-10 mx-auto mb-3 opacity-50" />
<p>Este ingrediente no tiene lotes individuales registrados</p>
<p className="text-xs mt-2">Se muestra información agregada del stock</p>
</div>
<div className="grid grid-cols-2 gap-4 p-4 bg-[var(--surface-secondary)] rounded-lg">
<div>
<span className="text-[var(--text-secondary)] text-sm">Stock Total:</span>
<div className="font-medium">{ingredient.current_stock || 0} {ingredient.unit_of_measure}</div>
</div>
<div>
<span className="text-[var(--text-secondary)] text-sm">Costo Promedio:</span>
<div className="font-medium">{(ingredient.average_cost || 0).toFixed(2)}</div>
</div>
<div>
<span className="text-[var(--text-secondary)] text-sm">Umbral Mínimo:</span>
<div className="font-medium">{ingredient.low_stock_threshold} {ingredient.unit_of_measure}</div>
</div>
<div>
<span className="text-[var(--text-secondary)] text-sm">Punto de Reorden:</span>
<div className="font-medium">{ingredient.reorder_point} {ingredient.unit_of_measure}</div>
</div>
</div>
</div>
) : lotsDisplay,
span: 2 as const
}
]
}
];
// Add summary if we have lots
if (sortedLots.length > 0) {
const totalQuantity = sortedLots.reduce((sum, lot) => sum + (lot.current_quantity || lot.quantity || 0), 0);
const totalValue = sortedLots.reduce((sum, lot) => sum + (lot.total_cost || lot.total_value || 0), 0);
const expiringSoon = sortedLots.filter(lot => {
if (!lot.expiration_date) return false;
const daysUntilExpiry = Math.ceil((new Date(lot.expiration_date).getTime() - new Date().getTime()) / (1000 * 60 * 60 * 24));
return daysUntilExpiry <= 7;
}).length;
sections.unshift({
title: 'Resumen de Lotes',
icon: CheckCircle,
fields: [
{
label: 'Total Cantidad',
value: `${totalQuantity} ${ingredient.unit_of_measure}`,
highlight: true,
span: 1 as const
},
{
label: 'Número de Lotes',
value: sortedLots.length,
span: 1 as const
},
{
label: 'Valor Total',
value: formatters.currency(totalValue),
type: 'currency' as const,
span: 1 as const
},
{
label: 'Por Vencer (7 días)',
value: expiringSoon,
highlight: expiringSoon > 0,
span: 1 as const
}
]
});
}
return (
<StatusModal
isOpen={isOpen}
onClose={onClose}
mode="view"
title={`Lotes: ${ingredient.name}`}
subtitle={`${ingredient.category} • Gestión por lotes y vencimientos`}
statusIndicator={statusConfig}
sections={sections}
size="xl"
loading={loading}
showDefaultActions={false}
/>
);
};
export default StockLotsModal;

View File

@@ -1,213 +0,0 @@
import React, { useState } from 'react';
import { Minus, Package, FileText, AlertTriangle } from 'lucide-react';
import { StatusModal } from '../../ui/StatusModal/StatusModal';
import { IngredientResponse, StockMovementCreate } from '../../../api/types/inventory';
import { statusColors } from '../../../styles/colors';
interface UseStockModalProps {
isOpen: boolean;
onClose: () => void;
ingredient: IngredientResponse;
onUseStock?: (movementData: StockMovementCreate) => Promise<void>;
}
/**
* UseStockModal - Focused modal for recording stock consumption
* Quick form for production usage tracking
*/
export const UseStockModal: React.FC<UseStockModalProps> = ({
isOpen,
onClose,
ingredient,
onUseStock
}) => {
const [formData, setFormData] = useState({
quantity: 0,
reference_number: '',
notes: '',
reason_code: 'production_use'
});
const [loading, setLoading] = useState(false);
const [mode, setMode] = useState<'overview' | 'edit'>('edit');
const handleFieldChange = (_sectionIndex: number, fieldIndex: number, value: string | number) => {
const fields = ['quantity', 'reference_number', 'notes', 'reason_code'];
const fieldName = fields[fieldIndex] as keyof typeof formData;
setFormData(prev => ({
...prev,
[fieldName]: value
}));
};
const handleSave = async () => {
const currentStock = Number(ingredient.current_stock) || 0;
const requestedQuantity = Number(formData.quantity);
if (!requestedQuantity || requestedQuantity <= 0) {
alert('Por favor, ingresa una cantidad válida');
return;
}
if (requestedQuantity > currentStock) {
alert(`No hay suficiente stock. Disponible: ${currentStock} ${ingredient.unit_of_measure}`);
return;
}
setLoading(true);
try {
const movementData: StockMovementCreate = {
ingredient_id: ingredient.id,
movement_type: formData.reason_code === 'waste' ? 'waste' : 'production_use',
quantity: -requestedQuantity, // Negative for consumption
reference_number: formData.reference_number || undefined,
notes: formData.notes || undefined,
reason_code: formData.reason_code || undefined
};
if (onUseStock) {
await onUseStock(movementData);
}
// Reset form
setFormData({
quantity: 0,
reference_number: '',
notes: '',
reason_code: 'production_use'
});
onClose();
} catch (error) {
console.error('Error using stock:', error);
alert('Error al registrar el uso de stock. Por favor, intenta de nuevo.');
} finally {
setLoading(false);
}
};
const currentStock = Number(ingredient.current_stock) || 0;
const requestedQuantity = Number(formData.quantity) || 0;
const remainingStock = Math.max(0, currentStock - requestedQuantity);
const isLowStock = remainingStock <= (Number(ingredient.low_stock_threshold) || 0);
const statusConfig = {
color: statusColors.pending.primary,
text: 'Usar Stock',
icon: Minus
};
const reasonOptions = [
{ label: 'Uso en Producción', value: 'production_use' },
{ label: 'Merma/Desperdicio', value: 'waste' },
{ label: 'Ajuste de Inventario', value: 'adjustment' },
{ label: 'Transferencia', value: 'transfer' }
];
const sections = [
{
title: 'Consumo de Stock',
icon: Package,
fields: [
{
label: `Cantidad a Usar (${ingredient.unit_of_measure})`,
value: formData.quantity || 0,
type: 'number' as const,
editable: true,
required: true,
placeholder: `Máx: ${currentStock}`
},
{
label: 'Motivo',
value: formData.reason_code,
type: 'select' as const,
editable: true,
required: true,
options: reasonOptions
},
{
label: 'Referencia/Pedido',
value: formData.reference_number || '',
type: 'text' as const,
editable: true,
placeholder: 'Ej: PROD-2024-001',
span: 2 as const
}
]
},
{
title: 'Detalles Adicionales',
icon: FileText,
fields: [
{
label: 'Notas',
value: formData.notes || '',
type: 'text' as const,
editable: true,
placeholder: 'Detalles del uso, receta, observaciones...',
span: 2 as const
}
]
},
{
title: 'Resumen del Movimiento',
icon: isLowStock ? AlertTriangle : Package,
fields: [
{
label: 'Stock Actual',
value: `${currentStock} ${ingredient.unit_of_measure}`,
span: 1 as const
},
{
label: 'Stock Restante',
value: `${remainingStock} ${ingredient.unit_of_measure}`,
highlight: isLowStock,
span: 1 as const
},
...(isLowStock ? [{
label: 'Advertencia',
value: '⚠️ El stock quedará por debajo del umbral mínimo',
span: 2 as const
}] : [])
]
}
];
const actions = [
{
label: 'Cancelar',
variant: 'outline' as const,
onClick: onClose,
disabled: loading
},
{
label: formData.reason_code === 'waste' ? 'Registrar Merma' : 'Usar Stock',
variant: formData.reason_code === 'waste' ? 'outline' : 'primary' as const,
onClick: handleSave,
disabled: loading || !formData.quantity || requestedQuantity > currentStock,
loading
}
];
return (
<StatusModal
isOpen={isOpen}
onClose={onClose}
mode={mode}
onModeChange={setMode}
title={`Usar Stock: ${ingredient.name}`}
subtitle={`${ingredient.category} • Disponible: ${currentStock} ${ingredient.unit_of_measure}`}
statusIndicator={statusConfig}
sections={sections}
actions={actions}
onFieldChange={handleFieldChange}
onSave={handleSave}
size="md"
loading={loading}
showDefaultActions={false}
/>
);
};
export default UseStockModal;

View File

@@ -1,13 +1,10 @@
// Inventory Domain Components // Inventory Domain Components
// Focused Modal Components // Focused Modal Components
export { default as CreateItemModal } from './CreateItemModal'; export { default as CreateIngredientModal } from './CreateIngredientModal';
export { default as QuickViewModal } from './QuickViewModal'; export { default as ShowInfoModal } from './ShowInfoModal';
export { default as AddStockModal } from './AddStockModal'; export { default as StockHistoryModal } from './StockHistoryModal';
export { default as UseStockModal } from './UseStockModal'; export { default as BatchModal } from './BatchModal';
export { default as HistoryModal } from './HistoryModal';
export { default as StockLotsModal } from './StockLotsModal';
export { default as EditItemModal } from './EditItemModal';
export { default as DeleteIngredientModal } from './DeleteIngredientModal'; export { default as DeleteIngredientModal } from './DeleteIngredientModal';
// Re-export related types from inventory types // Re-export related types from inventory types

View File

@@ -1,11 +1,19 @@
import React, { forwardRef } from 'react'; import React, { forwardRef } from 'react';
import { clsx } from 'clsx'; import { clsx } from 'clsx';
import { baseColors, statusColors } from '../../../styles/colors';
/**
* ProgressBar component with global color system integration
*
* Supports both base color variants (with gradients) and status color variants (solid colors)
* - Base variants: default, success, warning, danger, info (use gradients from color scales)
* - Status variants: pending, inProgress, completed (use solid colors from statusColors)
*/
export interface ProgressBarProps { export interface ProgressBarProps {
value: number; value: number;
max?: number; max?: number;
size?: 'sm' | 'md' | 'lg'; size?: 'sm' | 'md' | 'lg';
variant?: 'default' | 'success' | 'warning' | 'danger'; variant?: 'default' | 'success' | 'warning' | 'danger' | 'info' | 'pending' | 'inProgress' | 'completed';
showLabel?: boolean; showLabel?: boolean;
label?: string; label?: string;
className?: string; className?: string;
@@ -33,11 +41,43 @@ const ProgressBar = forwardRef<HTMLDivElement, ProgressBarProps>(({
lg: 'h-4', lg: 'h-4',
}; };
const variantClasses = { const getVariantStyle = (variant: string) => {
default: 'bg-[var(--color-info)]', // Map base color variants
success: 'bg-[var(--color-success)]', const baseColorMap = {
warning: 'bg-[var(--color-warning)]', default: baseColors.primary,
danger: 'bg-[var(--color-error)]', success: baseColors.success,
warning: baseColors.warning,
danger: baseColors.error,
info: baseColors.info,
};
// Map status color variants (these use single colors from statusColors)
const statusColorMap = {
pending: statusColors.pending.primary,
inProgress: statusColors.inProgress.primary,
completed: statusColors.completed.primary,
};
// Check if it's a base color variant (has color scales)
if (variant in baseColorMap) {
const colors = baseColorMap[variant as keyof typeof baseColorMap];
return {
background: `linear-gradient(to right, ${colors[400]}, ${colors[500]})`,
};
}
// Check if it's a status color variant (single color)
if (variant in statusColorMap) {
const color = statusColorMap[variant as keyof typeof statusColorMap];
return {
backgroundColor: color,
};
}
// Default fallback
return {
background: `linear-gradient(to right, ${baseColors.primary[400]}, ${baseColors.primary[500]})`,
};
}; };
return ( return (
@@ -61,18 +101,22 @@ const ProgressBar = forwardRef<HTMLDivElement, ProgressBarProps>(({
<div <div
className={clsx( className={clsx(
'h-full rounded-full transition-all duration-300 ease-out relative overflow-hidden', 'h-full rounded-full transition-all duration-300 ease-out relative overflow-hidden',
!customColor && variantClasses[variant],
{ {
'animate-pulse': animated && percentage < 100, 'animate-pulse': animated && percentage < 100,
} }
)} )}
style={{ style={{
width: `${percentage}%`, width: `${percentage}%`,
backgroundColor: customColor || undefined ...(customColor ? { backgroundColor: customColor } : getVariantStyle(variant))
}} }}
> >
{animated && percentage < 100 && ( {animated && percentage < 100 && (
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-white to-transparent opacity-20 animate-pulse" /> <div
className="absolute inset-0 opacity-20 animate-pulse"
style={{
background: 'linear-gradient(to right, transparent, var(--text-inverse), transparent)'
}}
/>
)} )}
</div> </div>
</div> </div>

View File

@@ -241,69 +241,68 @@ export const StatusCard: React.FC<StatusCardProps> = ({
</div> </div>
)} )}
{/* Elegant Action System */} {/* Simplified Action System */}
{actions.length > 0 && ( {actions.length > 0 && (
<div className="pt-5"> <div className="pt-4 border-t border-[var(--border-primary)]">
{/* Primary Actions Row */} {/* All actions in a clean horizontal layout */}
<div className="flex items-center justify-between gap-2">
{/* Primary action as a subtle text button */}
{primaryActions.length > 0 && ( {primaryActions.length > 0 && (
<div className="flex gap-3 mb-3"> <button
<Button
variant={primaryActions[0].destructive ? 'outline' : 'primary'}
size="md"
className={`
flex-1 h-11 font-medium justify-center
${primaryActions[0].destructive
? 'border-red-300 text-red-600 hover:bg-red-50 hover:border-red-400'
: 'bg-gradient-to-r from-[var(--color-primary-600)] to-[var(--color-primary-500)] hover:from-[var(--color-primary-700)] hover:to-[var(--color-primary-600)] text-white border-transparent shadow-md hover:shadow-lg'
}
transition-all duration-200 transform hover:scale-[1.02] active:scale-[0.98]
`}
onClick={primaryActions[0].onClick} onClick={primaryActions[0].onClick}
>
{primaryActions[0].icon && React.createElement(primaryActions[0].icon, { className: "w-4 h-4 mr-2" })}
<span>{primaryActions[0].label}</span>
</Button>
{/* Secondary Action Button */}
{primaryActions.length > 1 && (
<Button
variant="outline"
size="md"
className="h-11 w-11 p-0 border-[var(--border-secondary)] hover:border-[var(--color-primary-400)] hover:bg-[var(--color-primary-50)] transition-all duration-200"
onClick={primaryActions[1].onClick}
title={primaryActions[1].label}
>
{primaryActions[1].icon && React.createElement(primaryActions[1].icon, { className: "w-4 h-4" })}
</Button>
)}
</div>
)}
{/* Secondary Actions Row - Smaller buttons */}
{secondaryActions.length > 0 && (
<div className="flex flex-wrap gap-2">
{secondaryActions.map((action, index) => (
<Button
key={`secondary-${index}`}
variant="outline"
size="sm"
className={` className={`
h-8 px-3 text-xs font-medium border-[var(--border-secondary)] flex items-center gap-2 px-3 py-2 text-sm font-medium rounded-lg
${action.destructive transition-all duration-200 hover:scale-105 active:scale-95
? 'text-red-600 border-red-200 hover:bg-red-50 hover:border-red-300' ${primaryActions[0].destructive
: 'text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:border-[var(--color-primary-300)] hover:bg-[var(--color-primary-50)]' ? 'text-red-600 hover:bg-red-50 hover:text-red-700'
: 'text-[var(--color-primary-600)] hover:bg-[var(--color-primary-50)] hover:text-[var(--color-primary-700)]'
} }
transition-all duration-200 flex items-center gap-1.5
`} `}
onClick={action.onClick}
> >
{action.icon && React.createElement(action.icon, { className: "w-3.5 h-3.5" })} {primaryActions[0].icon && React.createElement(primaryActions[0].icon, { className: "w-4 h-4" })}
<span>{action.label}</span> <span>{primaryActions[0].label}</span>
</Button> </button>
)}
{/* Action icons for secondary actions */}
<div className="flex items-center gap-1">
{secondaryActions.map((action, index) => (
<button
key={`action-${index}`}
onClick={action.onClick}
title={action.label}
className={`
p-2 rounded-lg transition-all duration-200 hover:scale-110 active:scale-95
${action.destructive
? 'text-red-500 hover:bg-red-50 hover:text-red-600'
: 'text-[var(--text-tertiary)] hover:text-[var(--text-primary)] hover:bg-[var(--surface-secondary)]'
}
`}
>
{action.icon && React.createElement(action.icon, { className: "w-4 h-4" })}
</button>
))}
{/* Include additional primary actions as icons */}
{primaryActions.slice(1).map((action, index) => (
<button
key={`primary-icon-${index}`}
onClick={action.onClick}
title={action.label}
className={`
p-2 rounded-lg transition-all duration-200 hover:scale-110 active:scale-95
${action.destructive
? 'text-red-500 hover:bg-red-50 hover:text-red-600'
: 'text-[var(--color-primary-500)] hover:text-[var(--color-primary-600)] hover:bg-[var(--color-primary-50)]'
}
`}
>
{action.icon && React.createElement(action.icon, { className: "w-4 h-4" })}
</button>
))} ))}
</div> </div>
)} </div>
</div> </div>
)} )}
</div> </div>

View File

@@ -59,21 +59,6 @@ export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
initializeAuth(); initializeAuth();
}, []); }, []);
// Set up token refresh interval
useEffect(() => {
if (authStore.isAuthenticated && authStore.token) {
const refreshInterval = setInterval(() => {
if (authStore.refreshToken) {
authStore.refreshAuth().catch(() => {
// Refresh failed, logout user
authStore.logout();
});
}
}, 14 * 60 * 1000); // Refresh every 14 minutes
return () => clearInterval(refreshInterval);
}
}, [authStore.isAuthenticated, authStore.token, authStore.refreshToken]);
const contextValue: AuthContextType = { const contextValue: AuthContextType = {
user: authStore.user, user: authStore.user,

View File

@@ -1,20 +1,20 @@
import React, { useState, useMemo } from 'react'; import React, { useState, useMemo } from 'react';
import { Plus, AlertTriangle, Package, CheckCircle, Eye, Clock, Euro, ArrowRight, Minus, Edit, Trash2 } from 'lucide-react'; import { Plus, AlertTriangle, Package, CheckCircle, Eye, Clock, Euro, ArrowRight, Minus, Edit, Trash2, Archive, TrendingUp, History } from 'lucide-react';
import { Button, Input, Card, StatsGrid, StatusCard, getStatusColor } from '../../../../components/ui'; import { Button, Input, Card, StatsGrid, StatusCard, getStatusColor } from '../../../../components/ui';
import { LoadingSpinner } from '../../../../components/shared'; import { LoadingSpinner } from '../../../../components/shared';
import { formatters } from '../../../../components/ui/Stats/StatsPresets'; import { formatters } from '../../../../components/ui/Stats/StatsPresets';
import { PageHeader } from '../../../../components/layout'; import { PageHeader } from '../../../../components/layout';
import { import {
CreateItemModal, CreateIngredientModal,
QuickViewModal, ShowInfoModal,
AddStockModal, StockHistoryModal,
UseStockModal, BatchModal,
HistoryModal,
StockLotsModal,
EditItemModal,
DeleteIngredientModal DeleteIngredientModal
} from '../../../../components/domain/inventory'; } from '../../../../components/domain/inventory';
import { useIngredients, useStockAnalytics, useStockMovements, useStockByIngredient, useCreateIngredient, useSoftDeleteIngredient, useHardDeleteIngredient } from '../../../../api/hooks/inventory';
// Import AddStockModal separately since we need it for adding batches
import AddStockModal from '../../../../components/domain/inventory/AddStockModal';
import { useIngredients, useStockAnalytics, useStockMovements, useStockByIngredient, useCreateIngredient, useSoftDeleteIngredient, useHardDeleteIngredient, useAddStock, useConsumeStock, useUpdateIngredient, useTransformationsByIngredient } from '../../../../api/hooks/inventory';
import { useCurrentTenant } from '../../../../stores/tenant.store'; import { useCurrentTenant } from '../../../../stores/tenant.store';
import { IngredientResponse, StockCreate, StockMovementCreate, IngredientCreate } from '../../../../api/types/inventory'; import { IngredientResponse, StockCreate, StockMovementCreate, IngredientCreate } from '../../../../api/types/inventory';
@@ -23,14 +23,12 @@ const InventoryPage: React.FC = () => {
const [selectedItem, setSelectedItem] = useState<IngredientResponse | null>(null); const [selectedItem, setSelectedItem] = useState<IngredientResponse | null>(null);
// Modal states for focused actions // Modal states for focused actions
const [showCreateItem, setShowCreateItem] = useState(false); const [showCreateIngredient, setShowCreateIngredient] = useState(false);
const [showQuickView, setShowQuickView] = useState(false); const [showInfo, setShowInfo] = useState(false);
const [showAddStock, setShowAddStock] = useState(false); const [showStockHistory, setShowStockHistory] = useState(false);
const [showUseStock, setShowUseStock] = useState(false); const [showBatches, setShowBatches] = useState(false);
const [showHistory, setShowHistory] = useState(false);
const [showStockLots, setShowStockLots] = useState(false);
const [showEdit, setShowEdit] = useState(false);
const [showDeleteModal, setShowDeleteModal] = useState(false); const [showDeleteModal, setShowDeleteModal] = useState(false);
const [showAddBatch, setShowAddBatch] = useState(false);
const currentTenant = useCurrentTenant(); const currentTenant = useCurrentTenant();
const tenantId = currentTenant?.id || ''; const tenantId = currentTenant?.id || '';
@@ -39,6 +37,9 @@ const InventoryPage: React.FC = () => {
const createIngredientMutation = useCreateIngredient(); const createIngredientMutation = useCreateIngredient();
const softDeleteMutation = useSoftDeleteIngredient(); const softDeleteMutation = useSoftDeleteIngredient();
const hardDeleteMutation = useHardDeleteIngredient(); const hardDeleteMutation = useHardDeleteIngredient();
const addStockMutation = useAddStock();
const consumeStockMutation = useConsumeStock();
const updateIngredientMutation = useUpdateIngredient();
// API Data // API Data
const { const {
@@ -69,10 +70,10 @@ const InventoryPage: React.FC = () => {
selectedItem?.id, selectedItem?.id,
50, 50,
0, 0,
{ enabled: !!selectedItem?.id && showHistory } { enabled: !!selectedItem?.id && showStockHistory }
); );
// Stock lots for stock lots modal // Stock lots for stock lots modal and history modal
const { const {
data: stockLotsData, data: stockLotsData,
isLoading: stockLotsLoading, isLoading: stockLotsLoading,
@@ -81,17 +82,31 @@ const InventoryPage: React.FC = () => {
tenantId, tenantId,
selectedItem?.id || '', selectedItem?.id || '',
false, // includeUnavailable false, // includeUnavailable
{ enabled: !!selectedItem?.id && showStockLots } { enabled: !!selectedItem?.id && showBatches }
); );
// Debug stock lots data // Transformations for history modal (not currently used in new design)
console.log('Stock lots hook state:', { const {
data: transformationsData,
isLoading: transformationsLoading
} = useTransformationsByIngredient(
tenantId,
selectedItem?.id || '',
50, // limit
{ enabled: false } // Disabled for now since transformations not shown in new modals
);
// Debug data
console.log('Inventory data debug:', {
selectedItem: selectedItem?.id, selectedItem: selectedItem?.id,
showStockLots, showBatches,
showStockHistory,
stockLotsData, stockLotsData,
stockLotsLoading, stockLotsLoading,
stockLotsError, stockLotsError,
enabled: !!selectedItem?.id && showStockLots transformationsData,
transformationsLoading,
enabled: !!selectedItem?.id && showBatches
}); });
@@ -267,41 +282,22 @@ const InventoryPage: React.FC = () => {
}; };
// Focused action handlers // Focused action handlers
const handleQuickView = (ingredient: IngredientResponse) => { const handleShowInfo = (ingredient: IngredientResponse) => {
setSelectedItem(ingredient); setSelectedItem(ingredient);
setShowQuickView(true); setShowInfo(true);
}; };
const handleAddStock = (ingredient: IngredientResponse) => { const handleShowStockHistory = (ingredient: IngredientResponse) => {
setSelectedItem(ingredient); setSelectedItem(ingredient);
setShowAddStock(true); setShowStockHistory(true);
}; };
const handleUseStock = (ingredient: IngredientResponse) => { const handleShowBatches = (ingredient: IngredientResponse) => {
setSelectedItem(ingredient); setSelectedItem(ingredient);
setShowUseStock(true); setShowBatches(true);
}; };
const handleHistory = (ingredient: IngredientResponse) => { // This function is now replaced by handleShowBatches
setSelectedItem(ingredient);
setShowHistory(true);
};
const handleStockLots = (ingredient: IngredientResponse) => {
console.log('🔍 Opening stock lots for ingredient:', {
id: ingredient.id,
name: ingredient.name,
current_stock: ingredient.current_stock,
category: ingredient.category
});
setSelectedItem(ingredient);
setShowStockLots(true);
};
const handleEdit = (ingredient: IngredientResponse) => {
setSelectedItem(ingredient);
setShowEdit(true);
};
const handleDelete = (ingredient: IngredientResponse) => { const handleDelete = (ingredient: IngredientResponse) => {
setSelectedItem(ingredient); setSelectedItem(ingredient);
@@ -310,7 +306,7 @@ const InventoryPage: React.FC = () => {
// Handle new item creation // Handle new item creation
const handleNewItem = () => { const handleNewItem = () => {
setShowCreateItem(true); setShowCreateIngredient(true);
}; };
// Handle creating a new ingredient // Handle creating a new ingredient
@@ -329,19 +325,32 @@ const InventoryPage: React.FC = () => {
// Modal action handlers // Modal action handlers
const handleAddStockSubmit = async (stockData: StockCreate) => { const handleAddStockSubmit = async (stockData: StockCreate) => {
console.log('Add stock:', stockData); if (!tenantId) {
// TODO: Implement API call throw new Error('No tenant ID available');
}
return addStockMutation.mutateAsync({
tenantId,
stockData
});
}; };
const handleUseStockSubmit = async (movementData: StockMovementCreate) => { const handleUseStockSubmit = async (movementData: StockMovementCreate) => {
console.log('Use stock:', movementData); if (!tenantId) {
// TODO: Implement API call throw new Error('No tenant ID available');
}
return consumeStockMutation.mutateAsync({
tenantId,
consumptionData: {
ingredient_id: movementData.ingredient_id,
quantity: Number(movementData.quantity),
reference_number: movementData.reference_number,
notes: movementData.notes
}
});
}; };
const handleUpdateIngredient = async (id: string, updateData: any) => {
console.log('Update ingredient:', id, updateData);
// TODO: Implement API call
};
// Delete handlers using mutation hooks // Delete handlers using mutation hooks
const handleSoftDelete = async (ingredientId: string) => { const handleSoftDelete = async (ingredientId: string) => {
@@ -557,49 +566,29 @@ const InventoryPage: React.FC = () => {
color: statusConfig.color color: statusConfig.color
} : undefined} } : undefined}
actions={[ actions={[
// Primary action - Most common user need // Primary action - View item details
{ {
label: currentStock === 0 ? 'Agregar Stock' : 'Ver Detalles', label: 'Ver Detalles',
icon: currentStock === 0 ? Plus : Eye, icon: Eye,
variant: currentStock === 0 ? 'primary' : 'outline', variant: 'primary',
priority: 'primary', priority: 'primary',
onClick: () => currentStock === 0 ? handleAddStock(ingredient) : handleQuickView(ingredient) onClick: () => handleShowInfo(ingredient)
},
// Secondary primary - Quick access to other main action
{
label: currentStock === 0 ? 'Ver Info' : 'Agregar',
icon: currentStock === 0 ? Eye : Plus,
variant: 'outline',
priority: 'primary',
onClick: () => currentStock === 0 ? handleQuickView(ingredient) : handleAddStock(ingredient)
},
// Secondary actions - Most used operations
{
label: 'Lotes',
icon: Package,
priority: 'secondary',
onClick: () => handleStockLots(ingredient)
},
{
label: 'Usar',
icon: Minus,
priority: 'secondary',
onClick: () => handleUseStock(ingredient)
}, },
// Stock history action - Icon button
{ {
label: 'Historial', label: 'Historial',
icon: Clock, icon: History,
priority: 'secondary', priority: 'secondary',
onClick: () => handleHistory(ingredient) onClick: () => handleShowStockHistory(ingredient)
}, },
// Least common action // Batch management action
{ {
label: 'Editar', label: 'Ver Lotes',
icon: Edit, icon: Package,
priority: 'secondary', priority: 'secondary',
onClick: () => handleEdit(ingredient) onClick: () => handleShowBatches(ingredient)
}, },
// Destructive action - separated for safety // Destructive action
{ {
label: 'Eliminar', label: 'Eliminar',
icon: Trash2, icon: Trash2,
@@ -637,48 +626,39 @@ const InventoryPage: React.FC = () => {
{/* Focused Action Modals */} {/* Focused Action Modals */}
{/* Create Item Modal - doesn't need selectedItem */} {/* Create Ingredient Modal - doesn't need selectedItem */}
<CreateItemModal <CreateIngredientModal
isOpen={showCreateItem} isOpen={showCreateIngredient}
onClose={() => setShowCreateItem(false)} onClose={() => setShowCreateIngredient(false)}
onCreateIngredient={handleCreateIngredient} onCreateIngredient={handleCreateIngredient}
/> />
{selectedItem && ( {selectedItem && (
<> <>
<QuickViewModal <ShowInfoModal
isOpen={showQuickView} isOpen={showInfo}
onClose={() => { onClose={() => {
setShowQuickView(false); setShowInfo(false);
setSelectedItem(null); setSelectedItem(null);
}} }}
ingredient={selectedItem} ingredient={selectedItem}
/> onSave={async (updatedData) => {
if (!tenantId || !selectedItem) {
throw new Error('Missing tenant ID or selected item');
}
<AddStockModal return updateIngredientMutation.mutateAsync({
isOpen={showAddStock} tenantId,
onClose={() => { ingredientId: selectedItem.id,
setShowAddStock(false); updateData: updatedData
setSelectedItem(null); });
}} }}
ingredient={selectedItem}
onAddStock={handleAddStockSubmit}
/> />
<UseStockModal <StockHistoryModal
isOpen={showUseStock} isOpen={showStockHistory}
onClose={() => { onClose={() => {
setShowUseStock(false); setShowStockHistory(false);
setSelectedItem(null);
}}
ingredient={selectedItem}
onUseStock={handleUseStockSubmit}
/>
<HistoryModal
isOpen={showHistory}
onClose={() => {
setShowHistory(false);
setSelectedItem(null); setSelectedItem(null);
}} }}
ingredient={selectedItem} ingredient={selectedItem}
@@ -686,25 +666,26 @@ const InventoryPage: React.FC = () => {
loading={movementsLoading} loading={movementsLoading}
/> />
<StockLotsModal <BatchModal
isOpen={showStockLots} isOpen={showBatches}
onClose={() => { onClose={() => {
setShowStockLots(false); setShowBatches(false);
setSelectedItem(null); setSelectedItem(null);
}} }}
ingredient={selectedItem} ingredient={selectedItem}
stockLots={stockLotsData || []} batches={stockLotsData || []}
loading={stockLotsLoading} loading={stockLotsLoading}
/> onAddBatch={() => {
setShowAddBatch(true);
<EditItemModal }}
isOpen={showEdit} onEditBatch={async (batchId, updateData) => {
onClose={() => { // TODO: Implement edit batch functionality
setShowEdit(false); console.log('Edit batch:', batchId, updateData);
setSelectedItem(null); }}
onMarkAsWaste={async (batchId) => {
// TODO: Implement mark as waste functionality
console.log('Mark as waste:', batchId);
}} }}
ingredient={selectedItem}
onUpdateIngredient={handleUpdateIngredient}
/> />
<DeleteIngredientModal <DeleteIngredientModal
@@ -718,6 +699,15 @@ const InventoryPage: React.FC = () => {
onHardDelete={handleHardDelete} onHardDelete={handleHardDelete}
isLoading={softDeleteMutation.isPending || hardDeleteMutation.isPending} isLoading={softDeleteMutation.isPending || hardDeleteMutation.isPending}
/> />
<AddStockModal
isOpen={showAddBatch}
onClose={() => {
setShowAddBatch(false);
}}
ingredient={selectedItem}
onAddStock={handleAddStockSubmit}
/>
</> </>
)} )}
</div> </div>

View File

@@ -581,3 +581,22 @@ export const formatDateInTimezone = (
return formatDate(date, formatStr); return formatDate(date, formatStr);
} }
}; };
// Convert HTML date input (YYYY-MM-DD) to end-of-day datetime for API
export const formatExpirationDateForAPI = (dateString: string): string | undefined => {
try {
if (!dateString) return undefined;
// Parse the date string (YYYY-MM-DD format from HTML date input)
const dateObj = parseISO(dateString);
if (!isValid(dateObj)) {
return undefined;
}
// Set to end of day for expiration dates and return ISO string
return getEndOfDay(dateObj).toISOString();
} catch {
return undefined;
}
};

View File

@@ -75,14 +75,14 @@ class ProductionStage(enum.Enum):
class StockMovementType(enum.Enum): class StockMovementType(enum.Enum):
"""Types of inventory movements""" """Types of inventory movements"""
PURCHASE = "purchase" PURCHASE = "PURCHASE"
PRODUCTION_USE = "production_use" PRODUCTION_USE = "PRODUCTION_USE"
ADJUSTMENT = "adjustment" ADJUSTMENT = "ADJUSTMENT"
WASTE = "waste" WASTE = "WASTE"
TRANSFER = "transfer" TRANSFER = "TRANSFER"
RETURN = "return" RETURN = "RETURN"
INITIAL_STOCK = "initial_stock" INITIAL_STOCK = "INITIAL_STOCK"
TRANSFORMATION = "transformation" # Converting between production stages TRANSFORMATION = "TRANSFORMATION" # Converting between production stages
class Ingredient(Base): class Ingredient(Base):
@@ -121,15 +121,8 @@ class Ingredient(Base):
reorder_quantity = Column(Float, nullable=False, default=50.0) reorder_quantity = Column(Float, nullable=False, default=50.0)
max_stock_level = Column(Float, nullable=True) max_stock_level = Column(Float, nullable=True)
# Storage requirements (applies to both ingredients and finished products) # Shelf life (critical for finished products) - default values only
requires_refrigeration = Column(Boolean, default=False) shelf_life_days = Column(Integer, nullable=True) # Default shelf life - actual per batch
requires_freezing = Column(Boolean, default=False)
storage_temperature_min = Column(Float, nullable=True) # Celsius
storage_temperature_max = Column(Float, nullable=True) # Celsius
storage_humidity_max = Column(Float, nullable=True) # Percentage
# Shelf life (critical for finished products)
shelf_life_days = Column(Integer, nullable=True)
display_life_hours = Column(Integer, nullable=True) # How long can be displayed (for fresh products) display_life_hours = Column(Integer, nullable=True) # How long can be displayed (for fresh products)
best_before_hours = Column(Integer, nullable=True) # Hours until best before (for same-day products) best_before_hours = Column(Integer, nullable=True) # Hours until best before (for same-day products)
storage_instructions = Column(Text, nullable=True) storage_instructions = Column(Text, nullable=True)
@@ -211,11 +204,6 @@ class Ingredient(Base):
'reorder_point': self.reorder_point, 'reorder_point': self.reorder_point,
'reorder_quantity': self.reorder_quantity, 'reorder_quantity': self.reorder_quantity,
'max_stock_level': self.max_stock_level, 'max_stock_level': self.max_stock_level,
'requires_refrigeration': self.requires_refrigeration,
'requires_freezing': self.requires_freezing,
'storage_temperature_min': self.storage_temperature_min,
'storage_temperature_max': self.storage_temperature_max,
'storage_humidity_max': self.storage_humidity_max,
'shelf_life_days': self.shelf_life_days, 'shelf_life_days': self.shelf_life_days,
'display_life_hours': self.display_life_hours, 'display_life_hours': self.display_life_hours,
'best_before_hours': self.best_before_hours, 'best_before_hours': self.best_before_hours,
@@ -248,7 +236,7 @@ class Stock(Base):
supplier_batch_ref = Column(String(100), nullable=True) supplier_batch_ref = Column(String(100), nullable=True)
# Production stage tracking # Production stage tracking
production_stage = Column(String(20), nullable=False, default='raw_ingredient', index=True) production_stage = Column(SQLEnum('raw_ingredient', 'par_baked', 'fully_baked', 'prepared_dough', 'frozen_product', name='productionstage', create_type=False), nullable=False, default='raw_ingredient', index=True)
transformation_reference = Column(String(100), nullable=True, index=True) # Links related transformations transformation_reference = Column(String(100), nullable=True, index=True) # Links related transformations
# Quantities # Quantities
@@ -275,6 +263,15 @@ class Stock(Base):
warehouse_zone = Column(String(50), nullable=True) warehouse_zone = Column(String(50), nullable=True)
shelf_position = Column(String(50), nullable=True) shelf_position = Column(String(50), nullable=True)
# Batch-specific storage requirements
requires_refrigeration = Column(Boolean, default=False)
requires_freezing = Column(Boolean, default=False)
storage_temperature_min = Column(Float, nullable=True) # Celsius
storage_temperature_max = Column(Float, nullable=True) # Celsius
storage_humidity_max = Column(Float, nullable=True) # Percentage
shelf_life_days = Column(Integer, nullable=True) # Batch-specific shelf life
storage_instructions = Column(Text, nullable=True) # Batch-specific instructions
# Status # Status
is_available = Column(Boolean, default=True) is_available = Column(Boolean, default=True)
is_expired = Column(Boolean, default=False, index=True) is_expired = Column(Boolean, default=False, index=True)
@@ -325,6 +322,13 @@ class Stock(Base):
'storage_location': self.storage_location, 'storage_location': self.storage_location,
'warehouse_zone': self.warehouse_zone, 'warehouse_zone': self.warehouse_zone,
'shelf_position': self.shelf_position, 'shelf_position': self.shelf_position,
'requires_refrigeration': self.requires_refrigeration,
'requires_freezing': self.requires_freezing,
'storage_temperature_min': self.storage_temperature_min,
'storage_temperature_max': self.storage_temperature_max,
'storage_humidity_max': self.storage_humidity_max,
'shelf_life_days': self.shelf_life_days,
'storage_instructions': self.storage_instructions,
'is_available': self.is_available, 'is_available': self.is_available,
'is_expired': self.is_expired, 'is_expired': self.is_expired,
'quality_status': self.quality_status, 'quality_status': self.quality_status,
@@ -343,7 +347,7 @@ class StockMovement(Base):
stock_id = Column(UUID(as_uuid=True), ForeignKey('stock.id'), nullable=True, index=True) stock_id = Column(UUID(as_uuid=True), ForeignKey('stock.id'), nullable=True, index=True)
# Movement details # Movement details
movement_type = Column(SQLEnum(StockMovementType), nullable=False, index=True) movement_type = Column(SQLEnum('PURCHASE', 'PRODUCTION_USE', 'ADJUSTMENT', 'WASTE', 'TRANSFER', 'RETURN', 'INITIAL_STOCK', name='stockmovementtype', create_type=False), nullable=False, index=True)
quantity = Column(Float, nullable=False) quantity = Column(Float, nullable=False)
unit_cost = Column(Numeric(10, 2), nullable=True) unit_cost = Column(Numeric(10, 2), nullable=True)
total_cost = Column(Numeric(10, 2), nullable=True) total_cost = Column(Numeric(10, 2), nullable=True)
@@ -386,7 +390,7 @@ class StockMovement(Base):
'tenant_id': str(self.tenant_id), 'tenant_id': str(self.tenant_id),
'ingredient_id': str(self.ingredient_id), 'ingredient_id': str(self.ingredient_id),
'stock_id': str(self.stock_id) if self.stock_id else None, 'stock_id': str(self.stock_id) if self.stock_id else None,
'movement_type': self.movement_type.value if self.movement_type else None, 'movement_type': self.movement_type if self.movement_type else None,
'quantity': self.quantity, 'quantity': self.quantity,
'unit_cost': float(self.unit_cost) if self.unit_cost else None, 'unit_cost': float(self.unit_cost) if self.unit_cost else None,
'total_cost': float(self.total_cost) if self.total_cost else None, 'total_cost': float(self.total_cost) if self.total_cost else None,
@@ -415,8 +419,8 @@ class ProductTransformation(Base):
target_ingredient_id = Column(UUID(as_uuid=True), ForeignKey('ingredients.id'), nullable=False) target_ingredient_id = Column(UUID(as_uuid=True), ForeignKey('ingredients.id'), nullable=False)
# Stage transformation # Stage transformation
source_stage = Column(String(20), nullable=False) source_stage = Column(SQLEnum('raw_ingredient', 'par_baked', 'fully_baked', 'prepared_dough', 'frozen_product', name='productionstage', create_type=False), nullable=False)
target_stage = Column(String(20), nullable=False) target_stage = Column(SQLEnum('raw_ingredient', 'par_baked', 'fully_baked', 'prepared_dough', 'frozen_product', name='productionstage', create_type=False), nullable=False)
# Quantities and conversion # Quantities and conversion
source_quantity = Column(Float, nullable=False) # Input quantity source_quantity = Column(Float, nullable=False) # Input quantity

View File

@@ -395,7 +395,8 @@ class IngredientRepository(BaseRepository[Ingredient, IngredientCreate, Ingredie
async def update_last_purchase_price(self, ingredient_id: UUID, price: float) -> Optional[Ingredient]: async def update_last_purchase_price(self, ingredient_id: UUID, price: float) -> Optional[Ingredient]:
"""Update the last purchase price for an ingredient""" """Update the last purchase price for an ingredient"""
try: try:
update_data = {'last_purchase_price': price} from app.schemas.inventory import IngredientUpdate
update_data = IngredientUpdate(last_purchase_price=price)
return await self.update(ingredient_id, update_data) return await self.update(ingredient_id, update_data)
except Exception as e: except Exception as e:
@@ -443,3 +444,27 @@ class IngredientRepository(BaseRepository[Ingredient, IngredientCreate, Ingredie
await self.session.rollback() await self.session.rollback()
logger.error("Failed to hard delete ingredient", error=str(e), ingredient_id=ingredient_id, tenant_id=tenant_id) logger.error("Failed to hard delete ingredient", error=str(e), ingredient_id=ingredient_id, tenant_id=tenant_id)
raise raise
async def get_active_tenants(self) -> List[UUID]:
"""Get list of active tenant IDs from ingredients table"""
try:
result = await self.session.execute(
select(func.distinct(Ingredient.tenant_id))
.where(Ingredient.is_active == True)
)
tenant_ids = []
for row in result.fetchall():
tenant_id = row[0]
# Convert to UUID if it's not already
if isinstance(tenant_id, UUID):
tenant_ids.append(tenant_id)
else:
tenant_ids.append(UUID(str(tenant_id)))
logger.info("Retrieved active tenants from ingredients", count=len(tenant_ids))
return tenant_ids
except Exception as e:
logger.error("Failed to get active tenants from ingredients", error=str(e))
return []

View File

@@ -6,6 +6,7 @@ Stock Movement Repository using Repository Pattern
from typing import List, Optional, Dict, Any from typing import List, Optional, Dict, Any
from uuid import UUID from uuid import UUID
from datetime import datetime, timedelta from datetime import datetime, timedelta
from decimal import Decimal
from sqlalchemy import select, func, and_, or_, desc, asc from sqlalchemy import select, func, and_, or_, desc, asc
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
import structlog import structlog
@@ -36,13 +37,25 @@ class StockMovementRepository(BaseRepository[StockMovement, StockMovementCreate,
create_data['tenant_id'] = tenant_id create_data['tenant_id'] = tenant_id
create_data['created_by'] = created_by create_data['created_by'] = created_by
# Ensure movement_type is properly converted to enum value
if 'movement_type' in create_data:
movement_type = create_data['movement_type']
if hasattr(movement_type, 'value'):
# It's an enum object, use its value
create_data['movement_type'] = movement_type.value
elif isinstance(movement_type, str):
# It's already a string, ensure it's uppercase for database
create_data['movement_type'] = movement_type.upper()
# Set movement date if not provided # Set movement date if not provided
if not create_data.get('movement_date'): if not create_data.get('movement_date'):
create_data['movement_date'] = datetime.now() create_data['movement_date'] = datetime.now()
# Calculate total cost if unit cost provided # Calculate total cost if unit cost provided
if create_data.get('unit_cost') and create_data.get('quantity'): if create_data.get('unit_cost') and create_data.get('quantity'):
create_data['total_cost'] = create_data['unit_cost'] * create_data['quantity'] unit_cost = create_data['unit_cost']
quantity = Decimal(str(create_data['quantity']))
create_data['total_cost'] = unit_cost * quantity
# Create record # Create record
record = await self.create(create_data) record = await self.create(create_data)
@@ -50,7 +63,7 @@ class StockMovementRepository(BaseRepository[StockMovement, StockMovementCreate,
"Created stock movement", "Created stock movement",
movement_id=record.id, movement_id=record.id,
ingredient_id=record.ingredient_id, ingredient_id=record.ingredient_id,
movement_type=record.movement_type.value if record.movement_type else None, movement_type=record.movement_type if record.movement_type else None,
quantity=record.quantity, quantity=record.quantity,
tenant_id=tenant_id tenant_id=tenant_id
) )
@@ -234,7 +247,7 @@ class StockMovementRepository(BaseRepository[StockMovement, StockMovementCreate,
summary = {} summary = {}
for row in result: for row in result:
movement_type = row.movement_type.value if row.movement_type else "unknown" movement_type = row.movement_type if row.movement_type else "unknown"
summary[movement_type] = { summary[movement_type] = {
'count': row.count, 'count': row.count,
'total_quantity': float(row.total_quantity), 'total_quantity': float(row.total_quantity),
@@ -418,3 +431,64 @@ class StockMovementRepository(BaseRepository[StockMovement, StockMovementCreate,
tenant_id=str(tenant_id) tenant_id=str(tenant_id)
) )
raise raise
async def create_automatic_waste_movement(
self,
tenant_id: UUID,
ingredient_id: UUID,
stock_id: UUID,
quantity: float,
unit_cost: Optional[float],
batch_number: Optional[str],
expiration_date: datetime,
created_by: Optional[UUID] = None
) -> StockMovement:
"""Create an automatic waste movement for expired batches"""
try:
# Calculate total cost
total_cost = None
if unit_cost and quantity:
total_cost = Decimal(str(unit_cost)) * Decimal(str(quantity))
# Generate reference number
reference_number = f"AUTO-EXPIRE-{batch_number or stock_id}"
# Create movement data
movement_data = {
'tenant_id': tenant_id,
'ingredient_id': ingredient_id,
'stock_id': stock_id,
'movement_type': StockMovementType.WASTE.value,
'quantity': quantity,
'unit_cost': Decimal(str(unit_cost)) if unit_cost else None,
'total_cost': total_cost,
'quantity_before': quantity,
'quantity_after': 0,
'reference_number': reference_number,
'reason_code': 'expired',
'notes': f"Lote automáticamente marcado como caducado. Vencimiento: {expiration_date.strftime('%Y-%m-%d')}",
'movement_date': datetime.now(),
'created_by': created_by
}
# Create the movement record
movement = await self.create(movement_data)
logger.info("Created automatic waste movement for expired batch",
movement_id=str(movement.id),
tenant_id=str(tenant_id),
ingredient_id=str(ingredient_id),
stock_id=str(stock_id),
quantity=quantity,
batch_number=batch_number,
reference_number=reference_number)
return movement
except Exception as e:
logger.error("Failed to create automatic waste movement",
error=str(e),
tenant_id=str(tenant_id),
ingredient_id=str(ingredient_id),
stock_id=str(stock_id))
raise

View File

@@ -6,6 +6,7 @@ Stock Repository using Repository Pattern
from typing import List, Optional, Dict, Any, Tuple from typing import List, Optional, Dict, Any, Tuple
from uuid import UUID from uuid import UUID
from datetime import datetime, timedelta from datetime import datetime, timedelta
from decimal import Decimal
from sqlalchemy import select, func, and_, or_, desc, asc, update from sqlalchemy import select, func, and_, or_, desc, asc, update
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
import structlog import structlog
@@ -13,11 +14,12 @@ import structlog
from app.models.inventory import Stock, Ingredient from app.models.inventory import Stock, Ingredient
from app.schemas.inventory import StockCreate, StockUpdate from app.schemas.inventory import StockCreate, StockUpdate
from shared.database.repository import BaseRepository from shared.database.repository import BaseRepository
from shared.utils.batch_generator import BatchCountProvider
logger = structlog.get_logger() logger = structlog.get_logger()
class StockRepository(BaseRepository[Stock, StockCreate, StockUpdate]): class StockRepository(BaseRepository[Stock, StockCreate, StockUpdate], BatchCountProvider):
"""Repository for stock operations""" """Repository for stock operations"""
def __init__(self, session: AsyncSession): def __init__(self, session: AsyncSession):
@@ -30,13 +32,29 @@ class StockRepository(BaseRepository[Stock, StockCreate, StockUpdate]):
create_data = stock_data.model_dump() create_data = stock_data.model_dump()
create_data['tenant_id'] = tenant_id create_data['tenant_id'] = tenant_id
# Ensure production_stage is properly converted to enum value
if 'production_stage' in create_data:
if hasattr(create_data['production_stage'], 'value'):
create_data['production_stage'] = create_data['production_stage'].value
elif isinstance(create_data['production_stage'], str):
# If it's a string, ensure it's the correct enum value
from app.models.inventory import ProductionStage
try:
enum_obj = ProductionStage[create_data['production_stage']]
create_data['production_stage'] = enum_obj.value
except KeyError:
# If it's already the value, keep it as is
pass
# Calculate available quantity # Calculate available quantity
available_qty = create_data['current_quantity'] - create_data.get('reserved_quantity', 0) available_qty = create_data['current_quantity'] - create_data.get('reserved_quantity', 0)
create_data['available_quantity'] = max(0, available_qty) create_data['available_quantity'] = max(0, available_qty)
# Calculate total cost if unit cost provided # Calculate total cost if unit cost provided
if create_data.get('unit_cost') and create_data.get('current_quantity'): if create_data.get('unit_cost') and create_data.get('current_quantity'):
create_data['total_cost'] = create_data['unit_cost'] * create_data['current_quantity'] unit_cost = create_data['unit_cost']
current_quantity = Decimal(str(create_data['current_quantity']))
create_data['total_cost'] = unit_cost * current_quantity
# Create record # Create record
record = await self.create(create_data) record = await self.create(create_data)
@@ -525,3 +543,163 @@ class StockRepository(BaseRepository[Stock, StockCreate, StockUpdate]):
tenant_id=str(tenant_id) tenant_id=str(tenant_id)
) )
raise raise
async def get_daily_batch_count(
self,
tenant_id: str,
date_start: datetime,
date_end: datetime,
prefix: Optional[str] = None
) -> int:
"""Get the count of batches created today for the given tenant"""
try:
conditions = [
Stock.tenant_id == tenant_id,
Stock.created_at >= date_start,
Stock.created_at <= date_end
]
if prefix:
conditions.append(Stock.batch_number.like(f"{prefix}-%"))
stmt = select(func.count(Stock.id)).where(and_(*conditions))
result = await self.session.execute(stmt)
count = result.scalar() or 0
logger.debug(
"Retrieved daily batch count",
tenant_id=tenant_id,
prefix=prefix,
count=count,
date_start=date_start,
date_end=date_end
)
return count
except Exception as e:
logger.error(
"Failed to get daily batch count",
error=str(e),
tenant_id=tenant_id,
prefix=prefix
)
raise
async def get_expired_batches_for_processing(self, tenant_id: UUID) -> List[Tuple[Stock, Ingredient]]:
"""Get expired batches that haven't been processed yet (for automatic processing)"""
try:
current_date = datetime.now()
# Find expired batches that are still available and not yet marked as expired
result = await self.session.execute(
select(Stock, Ingredient)
.join(Ingredient, Stock.ingredient_id == Ingredient.id)
.where(
and_(
Stock.tenant_id == tenant_id,
Stock.is_available == True,
Stock.is_expired == False,
Stock.current_quantity > 0,
or_(
and_(
Stock.final_expiration_date.isnot(None),
Stock.final_expiration_date <= current_date
),
and_(
Stock.final_expiration_date.is_(None),
Stock.expiration_date.isnot(None),
Stock.expiration_date <= current_date
)
)
)
)
.order_by(
asc(func.coalesce(Stock.final_expiration_date, Stock.expiration_date))
)
)
expired_batches = result.all()
logger.info("Found expired batches for processing",
tenant_id=str(tenant_id),
count=len(expired_batches))
return expired_batches
except Exception as e:
logger.error("Failed to get expired batches for processing",
error=str(e), tenant_id=tenant_id)
raise
async def mark_batch_as_expired(self, stock_id: UUID, tenant_id: UUID) -> bool:
"""Mark a specific batch as expired and unavailable"""
try:
result = await self.session.execute(
update(Stock)
.where(
and_(
Stock.id == stock_id,
Stock.tenant_id == tenant_id
)
)
.values(
is_expired=True,
is_available=False,
quality_status="expired",
updated_at=datetime.now()
)
)
if result.rowcount > 0:
logger.info("Marked batch as expired",
stock_id=str(stock_id),
tenant_id=str(tenant_id))
return True
else:
logger.warning("No batch found to mark as expired",
stock_id=str(stock_id),
tenant_id=str(tenant_id))
return False
except Exception as e:
logger.error("Failed to mark batch as expired",
error=str(e),
stock_id=str(stock_id),
tenant_id=str(tenant_id))
raise
async def update_stock_to_zero(self, stock_id: UUID, tenant_id: UUID) -> bool:
"""Update stock quantities to zero after moving to waste"""
try:
result = await self.session.execute(
update(Stock)
.where(
and_(
Stock.id == stock_id,
Stock.tenant_id == tenant_id
)
)
.values(
current_quantity=0,
available_quantity=0,
updated_at=datetime.now()
)
)
if result.rowcount > 0:
logger.info("Updated stock quantities to zero",
stock_id=str(stock_id),
tenant_id=str(tenant_id))
return True
else:
logger.warning("No stock found to update to zero",
stock_id=str(stock_id),
tenant_id=str(tenant_id))
return False
except Exception as e:
logger.error("Failed to update stock to zero",
error=str(e),
stock_id=str(stock_id),
tenant_id=str(tenant_id))
raise

View File

@@ -54,16 +54,8 @@ class IngredientCreate(InventoryBaseSchema):
reorder_quantity: float = Field(50.0, gt=0, description="Default reorder quantity") reorder_quantity: float = Field(50.0, gt=0, description="Default reorder quantity")
max_stock_level: Optional[float] = Field(None, gt=0, description="Maximum stock level") max_stock_level: Optional[float] = Field(None, gt=0, description="Maximum stock level")
# Storage requirements # Shelf life (default value only - actual per batch)
requires_refrigeration: bool = Field(False, description="Requires refrigeration") shelf_life_days: Optional[int] = Field(None, gt=0, description="Default shelf life in days")
requires_freezing: bool = Field(False, description="Requires freezing")
storage_temperature_min: Optional[float] = Field(None, description="Min storage temperature (°C)")
storage_temperature_max: Optional[float] = Field(None, description="Max storage temperature (°C)")
storage_humidity_max: Optional[float] = Field(None, ge=0, le=100, description="Max humidity (%)")
# Shelf life
shelf_life_days: Optional[int] = Field(None, gt=0, description="Shelf life in days")
storage_instructions: Optional[str] = Field(None, description="Storage instructions")
# Properties # Properties
is_perishable: bool = Field(False, description="Is perishable") is_perishable: bool = Field(False, description="Is perishable")
@@ -106,16 +98,8 @@ class IngredientUpdate(InventoryBaseSchema):
reorder_quantity: Optional[float] = Field(None, gt=0, description="Default reorder quantity") reorder_quantity: Optional[float] = Field(None, gt=0, description="Default reorder quantity")
max_stock_level: Optional[float] = Field(None, gt=0, description="Maximum stock level") max_stock_level: Optional[float] = Field(None, gt=0, description="Maximum stock level")
# Storage requirements # Shelf life (default value only - actual per batch)
requires_refrigeration: Optional[bool] = Field(None, description="Requires refrigeration") shelf_life_days: Optional[int] = Field(None, gt=0, description="Default shelf life in days")
requires_freezing: Optional[bool] = Field(None, description="Requires freezing")
storage_temperature_min: Optional[float] = Field(None, description="Min storage temperature (°C)")
storage_temperature_max: Optional[float] = Field(None, description="Max storage temperature (°C)")
storage_humidity_max: Optional[float] = Field(None, ge=0, le=100, description="Max humidity (%)")
# Shelf life
shelf_life_days: Optional[int] = Field(None, gt=0, description="Shelf life in days")
storage_instructions: Optional[str] = Field(None, description="Storage instructions")
# Properties # Properties
is_active: Optional[bool] = Field(None, description="Is active") is_active: Optional[bool] = Field(None, description="Is active")
@@ -144,13 +128,7 @@ class IngredientResponse(InventoryBaseSchema):
reorder_point: float reorder_point: float
reorder_quantity: float reorder_quantity: float
max_stock_level: Optional[float] max_stock_level: Optional[float]
requires_refrigeration: bool shelf_life_days: Optional[int] # Default value only
requires_freezing: bool
storage_temperature_min: Optional[float]
storage_temperature_max: Optional[float]
storage_humidity_max: Optional[float]
shelf_life_days: Optional[int]
storage_instructions: Optional[str]
is_active: bool is_active: bool
is_perishable: bool is_perishable: bool
allergen_info: Optional[Dict[str, Any]] allergen_info: Optional[Dict[str, Any]]
@@ -174,7 +152,7 @@ class StockCreate(InventoryBaseSchema):
supplier_batch_ref: Optional[str] = Field(None, max_length=100, description="Supplier batch reference") supplier_batch_ref: Optional[str] = Field(None, max_length=100, description="Supplier batch reference")
# Production stage tracking # Production stage tracking
production_stage: ProductionStage = Field(ProductionStage.RAW_INGREDIENT, description="Production stage of the stock") production_stage: ProductionStage = Field(default=ProductionStage.RAW_INGREDIENT, description="Production stage of the stock")
transformation_reference: Optional[str] = Field(None, max_length=100, description="Transformation reference ID") transformation_reference: Optional[str] = Field(None, max_length=100, description="Transformation reference ID")
current_quantity: float = Field(..., ge=0, description="Current quantity") current_quantity: float = Field(..., ge=0, description="Current quantity")
@@ -194,6 +172,15 @@ class StockCreate(InventoryBaseSchema):
quality_status: str = Field("good", description="Quality status") quality_status: str = Field("good", description="Quality status")
# Batch-specific storage requirements
requires_refrigeration: bool = Field(False, description="Requires refrigeration")
requires_freezing: bool = Field(False, description="Requires freezing")
storage_temperature_min: Optional[float] = Field(None, description="Min storage temperature (°C)")
storage_temperature_max: Optional[float] = Field(None, description="Max storage temperature (°C)")
storage_humidity_max: Optional[float] = Field(None, ge=0, le=100, description="Max humidity (%)")
shelf_life_days: Optional[int] = Field(None, gt=0, description="Batch-specific shelf life in days")
storage_instructions: Optional[str] = Field(None, description="Batch-specific storage instructions")
class StockUpdate(InventoryBaseSchema): class StockUpdate(InventoryBaseSchema):
"""Schema for updating stock entries""" """Schema for updating stock entries"""
@@ -224,6 +211,15 @@ class StockUpdate(InventoryBaseSchema):
is_available: Optional[bool] = Field(None, description="Is available") is_available: Optional[bool] = Field(None, description="Is available")
quality_status: Optional[str] = Field(None, description="Quality status") quality_status: Optional[str] = Field(None, description="Quality status")
# Batch-specific storage requirements
requires_refrigeration: Optional[bool] = Field(None, description="Requires refrigeration")
requires_freezing: Optional[bool] = Field(None, description="Requires freezing")
storage_temperature_min: Optional[float] = Field(None, description="Min storage temperature (°C)")
storage_temperature_max: Optional[float] = Field(None, description="Max storage temperature (°C)")
storage_humidity_max: Optional[float] = Field(None, ge=0, le=100, description="Max humidity (%)")
shelf_life_days: Optional[int] = Field(None, gt=0, description="Batch-specific shelf life in days")
storage_instructions: Optional[str] = Field(None, description="Batch-specific storage instructions")
class StockResponse(InventoryBaseSchema): class StockResponse(InventoryBaseSchema):
"""Schema for stock API responses""" """Schema for stock API responses"""
@@ -258,6 +254,15 @@ class StockResponse(InventoryBaseSchema):
is_available: bool is_available: bool
is_expired: bool is_expired: bool
quality_status: str quality_status: str
# Batch-specific storage requirements
requires_refrigeration: bool
requires_freezing: bool
storage_temperature_min: Optional[float]
storage_temperature_max: Optional[float]
storage_humidity_max: Optional[float]
shelf_life_days: Optional[int]
storage_instructions: Optional[str]
created_at: datetime created_at: datetime
updated_at: datetime updated_at: datetime

View File

@@ -6,15 +6,18 @@ Implements hybrid detection patterns for critical stock issues and optimization
import asyncio import asyncio
import json import json
import uuid
from typing import List, Dict, Any, Optional from typing import List, Dict, Any, Optional
from uuid import UUID from uuid import UUID
from datetime import datetime, timedelta from datetime import datetime, timedelta, timezone
import structlog import structlog
from apscheduler.triggers.cron import CronTrigger from apscheduler.triggers.cron import CronTrigger
from sqlalchemy import text from sqlalchemy import text
from shared.alerts.base_service import BaseAlertService, AlertServiceMixin from shared.alerts.base_service import BaseAlertService, AlertServiceMixin
from shared.alerts.templates import format_item_message from shared.alerts.templates import format_item_message
from app.repositories.stock_repository import StockRepository
from app.repositories.stock_movement_repository import StockMovementRepository
logger = structlog.get_logger() logger = structlog.get_logger()
@@ -71,6 +74,15 @@ class InventoryAlertService(BaseAlertService, AlertServiceMixin):
max_instances=1 max_instances=1
) )
# Expired batch detection - daily at 6:00 AM (alerts and automated processing)
self.scheduler.add_job(
self.check_and_process_expired_batches,
CronTrigger(hour=6, minute=0), # Daily at 6:00 AM
id='expired_batch_processing',
misfire_grace_time=1800, # 30 minute grace time
max_instances=1
)
logger.info("Inventory alert schedules configured", logger.info("Inventory alert schedules configured",
service=self.config.SERVICE_NAME) service=self.config.SERVICE_NAME)
@@ -771,3 +783,192 @@ class InventoryAlertService(BaseAlertService, AlertServiceMixin):
ingredient_id=ingredient_id, ingredient_id=ingredient_id,
error=str(e)) error=str(e))
return None return None
async def check_and_process_expired_batches(self):
"""Daily check and automated processing of expired stock batches"""
try:
self._checks_performed += 1
# Use existing method to get active tenants from ingredients table
tenants = await self.get_active_tenants()
if not tenants:
logger.info("No active tenants found")
return
total_processed = 0
for tenant_id in tenants:
try:
# Get expired batches for each tenant
async with self.db_manager.get_background_session() as session:
stock_repo = StockRepository(session)
expired_batches = await stock_repo.get_expired_batches_for_processing(tenant_id)
if expired_batches:
processed_count = await self._process_expired_batches_for_tenant(tenant_id, expired_batches)
total_processed += processed_count
except Exception as e:
logger.error("Error processing expired batches for tenant",
tenant_id=str(tenant_id),
error=str(e))
logger.info("Expired batch processing completed",
total_processed=total_processed,
tenants_processed=len(tenants))
except Exception as e:
logger.error("Expired batch processing failed", error=str(e))
self._errors_count += 1
async def _process_expired_batches_for_tenant(self, tenant_id: UUID, batches: List[tuple]) -> int:
"""Process expired batches for a specific tenant"""
processed_count = 0
processed_batches = []
try:
for stock, ingredient in batches:
try:
# Process each batch individually with its own transaction
await self._process_single_expired_batch(tenant_id, stock, ingredient)
processed_count += 1
processed_batches.append((stock, ingredient))
except Exception as e:
logger.error("Error processing individual expired batch",
tenant_id=str(tenant_id),
stock_id=str(stock.id),
batch_number=stock.batch_number,
error=str(e))
# Generate summary alert for the tenant if any batches were processed
if processed_count > 0:
await self._generate_expired_batch_summary_alert(tenant_id, processed_batches)
except Exception as e:
logger.error("Error processing expired batches for tenant",
tenant_id=str(tenant_id),
error=str(e))
return processed_count
async def _process_single_expired_batch(self, tenant_id: UUID, stock, ingredient):
"""Process a single expired batch: mark as expired, create waste movement, update stock"""
async with self.db_manager.get_background_session() as session:
async with session.begin(): # Use transaction for consistency
try:
stock_repo = StockRepository(session)
movement_repo = StockMovementRepository(session)
# Calculate effective expiration date
effective_expiration_date = stock.final_expiration_date or stock.expiration_date
# 1. Mark the stock batch as expired
await stock_repo.mark_batch_as_expired(stock.id, tenant_id)
# 2. Create waste stock movement
await movement_repo.create_automatic_waste_movement(
tenant_id=tenant_id,
ingredient_id=stock.ingredient_id,
stock_id=stock.id,
quantity=stock.current_quantity,
unit_cost=float(stock.unit_cost) if stock.unit_cost else None,
batch_number=stock.batch_number,
expiration_date=effective_expiration_date,
created_by=None # Automatic system operation
)
# 3. Update the stock quantity to 0 (moved to waste)
await stock_repo.update_stock_to_zero(stock.id, tenant_id)
# Calculate days expired
days_expired = (datetime.now().date() - effective_expiration_date.date()).days if effective_expiration_date else 0
logger.info("Expired batch processed successfully",
tenant_id=str(tenant_id),
stock_id=str(stock.id),
ingredient_name=ingredient.name,
batch_number=stock.batch_number,
quantity_wasted=stock.current_quantity,
days_expired=days_expired)
except Exception as e:
logger.error("Error in expired batch transaction",
stock_id=str(stock.id),
error=str(e))
raise # Re-raise to trigger rollback
async def _generate_expired_batch_summary_alert(self, tenant_id: UUID, processed_batches: List[tuple]):
"""Generate summary alert for automatically processed expired batches"""
try:
total_batches = len(processed_batches)
total_quantity = sum(float(stock.current_quantity) for stock, ingredient in processed_batches)
# Get the most affected ingredients (top 3)
ingredient_summary = {}
for stock, ingredient in processed_batches:
ingredient_name = ingredient.name
if ingredient_name not in ingredient_summary:
ingredient_summary[ingredient_name] = {
'quantity': 0,
'batches': 0,
'unit': ingredient.unit_of_measure.value if ingredient.unit_of_measure else 'kg'
}
ingredient_summary[ingredient_name]['quantity'] += float(stock.current_quantity)
ingredient_summary[ingredient_name]['batches'] += 1
# Sort by quantity and get top 3
top_ingredients = sorted(ingredient_summary.items(),
key=lambda x: x[1]['quantity'],
reverse=True)[:3]
# Build ingredient list for message
ingredient_list = []
for name, info in top_ingredients:
ingredient_list.append(f"{name} ({info['quantity']:.1f}{info['unit']}, {info['batches']} lote{'s' if info['batches'] > 1 else ''})")
remaining_count = total_batches - sum(info['batches'] for _, info in top_ingredients)
if remaining_count > 0:
ingredient_list.append(f"y {remaining_count} lote{'s' if remaining_count > 1 else ''} más")
# Create alert message
title = f"🗑️ Lotes Caducados Procesados Automáticamente"
message = (
f"Se han procesado automáticamente {total_batches} lote{'s' if total_batches > 1 else ''} "
f"caducado{'s' if total_batches > 1 else ''} ({total_quantity:.1f}kg total) y se ha{'n' if total_batches > 1 else ''} "
f"movido automáticamente a desperdicio:\n\n"
f"{chr(10).join(ingredient_list)}\n\n"
f"Los lotes han sido marcados como no disponibles y se han generado los movimientos de desperdicio correspondientes."
)
await self.publish_item(tenant_id, {
'type': 'expired_batches_auto_processed',
'severity': 'medium',
'title': title,
'message': message,
'actions': [
'Revisar movimientos de desperdicio',
'Analizar causas de caducidad',
'Ajustar niveles de stock',
'Revisar rotación de inventario'
],
'metadata': {
'total_batches_processed': total_batches,
'total_quantity_wasted': total_quantity,
'processing_date': datetime.now(timezone.utc).isoformat(),
'affected_ingredients': [
{
'name': name,
'quantity_wasted': info['quantity'],
'batches_count': info['batches'],
'unit': info['unit']
} for name, info in ingredient_summary.items()
],
'automation_source': 'daily_expired_batch_check'
}
}, item_type='alert')
except Exception as e:
logger.error("Error generating expired batch summary alert",
tenant_id=str(tenant_id),
error=str(e))

View File

@@ -20,6 +20,7 @@ from app.schemas.inventory import (
) )
from app.core.database import get_db_transaction from app.core.database import get_db_transaction
from shared.database.exceptions import DatabaseError from shared.database.exceptions import DatabaseError
from shared.utils.batch_generator import BatchNumberGenerator, create_fallback_batch_number
logger = structlog.get_logger() logger = structlog.get_logger()
@@ -238,6 +239,20 @@ class InventoryService:
if not ingredient or ingredient.tenant_id != tenant_id: if not ingredient or ingredient.tenant_id != tenant_id:
raise ValueError("Ingredient not found") raise ValueError("Ingredient not found")
# Generate batch number if not provided
if not stock_data.batch_number:
try:
batch_generator = BatchNumberGenerator(stock_repo)
stock_data.batch_number = await batch_generator.generate_batch_number(
tenant_id=str(tenant_id),
prefix="INV"
)
logger.info("Generated batch number", batch_number=stock_data.batch_number)
except Exception as e:
# Fallback to a simple batch number if generation fails
stock_data.batch_number = create_fallback_batch_number("INV")
logger.warning("Used fallback batch number", batch_number=stock_data.batch_number, error=str(e))
# Create stock entry # Create stock entry
stock = await stock_repo.create_stock_entry(stock_data, tenant_id) stock = await stock_repo.create_stock_entry(stock_data, tenant_id)

View File

@@ -2,7 +2,7 @@
[alembic] [alembic]
# path to migration scripts # path to migration scripts
script_location = migrations script_location = .
# template used to generate migration files # template used to generate migration files
# file_template = %%(rev)s_%%(slug)s # file_template = %%(rev)s_%%(slug)s

View File

@@ -0,0 +1,114 @@
"""Add production stage enum and columns
Revision ID: 003
Revises: 002
Create Date: 2025-01-17 15:30:00.000000
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision = '003'
down_revision = '002'
branch_labels = None
depends_on = None
def upgrade() -> None:
# Create ProductionStage enum type
op.execute("""
CREATE TYPE productionstage AS ENUM (
'raw_ingredient', 'par_baked', 'fully_baked',
'prepared_dough', 'frozen_product'
);
""")
# Add production_stage column to stock table
op.add_column('stock', sa.Column('production_stage',
sa.Enum('raw_ingredient', 'par_baked', 'fully_baked', 'prepared_dough', 'frozen_product', name='productionstage'),
nullable=False, server_default='raw_ingredient'))
# Add transformation_reference column to stock table
op.add_column('stock', sa.Column('transformation_reference', sa.String(100), nullable=True))
# Add stage-specific expiration tracking columns
op.add_column('stock', sa.Column('original_expiration_date', sa.DateTime(timezone=True), nullable=True))
op.add_column('stock', sa.Column('transformation_date', sa.DateTime(timezone=True), nullable=True))
op.add_column('stock', sa.Column('final_expiration_date', sa.DateTime(timezone=True), nullable=True))
# Create product_transformations table
op.create_table(
'product_transformations',
sa.Column('id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('tenant_id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('transformation_reference', sa.String(100), nullable=False),
sa.Column('source_ingredient_id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('target_ingredient_id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('source_stage', sa.Enum('raw_ingredient', 'par_baked', 'fully_baked', 'prepared_dough', 'frozen_product', name='productionstage'), nullable=False),
sa.Column('target_stage', sa.Enum('raw_ingredient', 'par_baked', 'fully_baked', 'prepared_dough', 'frozen_product', name='productionstage'), nullable=False),
sa.Column('source_quantity', sa.Float(), nullable=False),
sa.Column('target_quantity', sa.Float(), nullable=False),
sa.Column('conversion_ratio', sa.Float(), nullable=False, server_default='1.0'),
sa.Column('expiration_calculation_method', sa.String(50), nullable=False, server_default='days_from_transformation'),
sa.Column('expiration_days_offset', sa.Integer(), nullable=True),
sa.Column('transformation_date', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('process_notes', sa.Text(), nullable=True),
sa.Column('performed_by', postgresql.UUID(as_uuid=True), nullable=True),
sa.Column('source_batch_numbers', sa.Text(), nullable=True),
sa.Column('target_batch_number', sa.String(100), nullable=True),
sa.Column('is_completed', sa.Boolean(), nullable=True, server_default='true'),
sa.Column('is_reversed', sa.Boolean(), nullable=True, server_default='false'),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('created_by', postgresql.UUID(as_uuid=True), nullable=True),
sa.ForeignKeyConstraint(['source_ingredient_id'], ['ingredients.id'], ),
sa.ForeignKeyConstraint(['target_ingredient_id'], ['ingredients.id'], ),
sa.PrimaryKeyConstraint('id')
)
# Add new indexes for enhanced functionality
op.create_index('idx_stock_production_stage', 'stock', ['tenant_id', 'production_stage', 'is_available'])
op.create_index('idx_stock_transformation', 'stock', ['tenant_id', 'transformation_reference'])
op.create_index('idx_stock_final_expiration', 'stock', ['tenant_id', 'final_expiration_date', 'is_available'])
# Create indexes for product_transformations table
op.create_index('idx_transformations_tenant_date', 'product_transformations', ['tenant_id', 'transformation_date'])
op.create_index('idx_transformations_reference', 'product_transformations', ['transformation_reference'])
op.create_index('idx_transformations_source', 'product_transformations', ['tenant_id', 'source_ingredient_id'])
op.create_index('idx_transformations_target', 'product_transformations', ['tenant_id', 'target_ingredient_id'])
op.create_index('idx_transformations_stages', 'product_transformations', ['source_stage', 'target_stage'])
# Update existing stockmovementtype enum to include TRANSFORMATION
op.execute("ALTER TYPE stockmovementtype ADD VALUE 'transformation';")
def downgrade() -> None:
# Drop indexes for product_transformations
op.drop_index('idx_transformations_stages', table_name='product_transformations')
op.drop_index('idx_transformations_target', table_name='product_transformations')
op.drop_index('idx_transformations_source', table_name='product_transformations')
op.drop_index('idx_transformations_reference', table_name='product_transformations')
op.drop_index('idx_transformations_tenant_date', table_name='product_transformations')
# Drop new stock indexes
op.drop_index('idx_stock_final_expiration', table_name='stock')
op.drop_index('idx_stock_transformation', table_name='stock')
op.drop_index('idx_stock_production_stage', table_name='stock')
# Drop product_transformations table
op.drop_table('product_transformations')
# Remove new columns from stock table
op.drop_column('stock', 'final_expiration_date')
op.drop_column('stock', 'transformation_date')
op.drop_column('stock', 'original_expiration_date')
op.drop_column('stock', 'transformation_reference')
op.drop_column('stock', 'production_stage')
# Drop ProductionStage enum type
op.execute("DROP TYPE productionstage;")
# Note: Cannot easily remove 'transformation' from existing enum in PostgreSQL
# This would require recreating the enum and updating all references
# For now, we leave the enum value as it won't cause issues

View File

@@ -0,0 +1,104 @@
"""Move storage configuration from ingredient to batch level
Revision ID: 004_move_storage_config_to_batch
Revises: 003_add_production_stage_enum
Create Date: 2025-01-17 10:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '004_move_storage_config_to_batch'
down_revision = '003_add_production_stage_enum'
branch_labels = None
depends_on = None
def upgrade():
"""Move storage configuration from ingredients to stock batches"""
# Add batch-specific storage columns to stock table
op.add_column('stock', sa.Column('requires_refrigeration', sa.Boolean(), default=False))
op.add_column('stock', sa.Column('requires_freezing', sa.Boolean(), default=False))
op.add_column('stock', sa.Column('storage_temperature_min', sa.Float(), nullable=True))
op.add_column('stock', sa.Column('storage_temperature_max', sa.Float(), nullable=True))
op.add_column('stock', sa.Column('storage_humidity_max', sa.Float(), nullable=True))
op.add_column('stock', sa.Column('shelf_life_days', sa.Integer(), nullable=True))
op.add_column('stock', sa.Column('storage_instructions', sa.Text(), nullable=True))
# Migrate existing data from ingredients to stock batches
# This will copy the ingredient-level storage config to all existing stock batches
op.execute("""
UPDATE stock
SET
requires_refrigeration = i.requires_refrigeration,
requires_freezing = i.requires_freezing,
storage_temperature_min = i.storage_temperature_min,
storage_temperature_max = i.storage_temperature_max,
storage_humidity_max = i.storage_humidity_max,
shelf_life_days = i.shelf_life_days,
storage_instructions = i.storage_instructions
FROM ingredients i
WHERE stock.ingredient_id = i.id
""")
# Remove storage configuration columns from ingredients table
# Keep only shelf_life_days as default value
op.drop_column('ingredients', 'requires_refrigeration')
op.drop_column('ingredients', 'requires_freezing')
op.drop_column('ingredients', 'storage_temperature_min')
op.drop_column('ingredients', 'storage_temperature_max')
op.drop_column('ingredients', 'storage_humidity_max')
op.drop_column('ingredients', 'storage_instructions')
def downgrade():
"""Revert storage configuration back to ingredient level"""
# Add storage configuration columns back to ingredients table
op.add_column('ingredients', sa.Column('requires_refrigeration', sa.Boolean(), default=False))
op.add_column('ingredients', sa.Column('requires_freezing', sa.Boolean(), default=False))
op.add_column('ingredients', sa.Column('storage_temperature_min', sa.Float(), nullable=True))
op.add_column('ingredients', sa.Column('storage_temperature_max', sa.Float(), nullable=True))
op.add_column('ingredients', sa.Column('storage_humidity_max', sa.Float(), nullable=True))
op.add_column('ingredients', sa.Column('storage_instructions', sa.Text(), nullable=True))
# Migrate data back from stock to ingredients (use most common values per ingredient)
op.execute("""
UPDATE ingredients
SET
requires_refrigeration = COALESCE(
(SELECT bool_or(s.requires_refrigeration) FROM stock s WHERE s.ingredient_id = ingredients.id),
false
),
requires_freezing = COALESCE(
(SELECT bool_or(s.requires_freezing) FROM stock s WHERE s.ingredient_id = ingredients.id),
false
),
storage_temperature_min = (
SELECT MIN(s.storage_temperature_min) FROM stock s WHERE s.ingredient_id = ingredients.id
),
storage_temperature_max = (
SELECT MAX(s.storage_temperature_max) FROM stock s WHERE s.ingredient_id = ingredients.id
),
storage_humidity_max = (
SELECT MAX(s.storage_humidity_max) FROM stock s WHERE s.ingredient_id = ingredients.id
),
storage_instructions = (
SELECT s.storage_instructions FROM stock s
WHERE s.ingredient_id = ingredients.id
AND s.storage_instructions IS NOT NULL
LIMIT 1
)
""")
# Remove batch-specific storage columns from stock table
op.drop_column('stock', 'requires_refrigeration')
op.drop_column('stock', 'requires_freezing')
op.drop_column('stock', 'storage_temperature_min')
op.drop_column('stock', 'storage_temperature_max')
op.drop_column('stock', 'storage_humidity_max')
op.drop_column('stock', 'shelf_life_days')
op.drop_column('stock', 'storage_instructions')

View File

@@ -14,11 +14,12 @@ from .base import ProductionBaseRepository
from app.models.production import ProductionBatch, ProductionStatus, ProductionPriority from app.models.production import ProductionBatch, ProductionStatus, ProductionPriority
from shared.database.exceptions import DatabaseError, ValidationError from shared.database.exceptions import DatabaseError, ValidationError
from shared.database.transactions import transactional from shared.database.transactions import transactional
from shared.utils.batch_generator import BatchCountProvider, BatchNumberGenerator, create_fallback_batch_number
logger = structlog.get_logger() logger = structlog.get_logger()
class ProductionBatchRepository(ProductionBaseRepository): class ProductionBatchRepository(ProductionBaseRepository, BatchCountProvider):
"""Repository for production batch operations""" """Repository for production batch operations"""
def __init__(self, session: AsyncSession, cache_ttl: Optional[int] = 300): def __init__(self, session: AsyncSession, cache_ttl: Optional[int] = 300):
@@ -41,9 +42,17 @@ class ProductionBatchRepository(ProductionBaseRepository):
# Generate batch number if not provided # Generate batch number if not provided
if "batch_number" not in batch_data or not batch_data["batch_number"]: if "batch_number" not in batch_data or not batch_data["batch_number"]:
batch_data["batch_number"] = await self._generate_batch_number( try:
batch_data["tenant_id"] batch_generator = BatchNumberGenerator(self)
batch_data["batch_number"] = await batch_generator.generate_batch_number(
tenant_id=batch_data["tenant_id"],
prefix="PROD"
) )
logger.info("Generated production batch number", batch_number=batch_data["batch_number"])
except Exception as e:
# Fallback to a simple batch number if generation fails
batch_data["batch_number"] = create_fallback_batch_number("PROD")
logger.warning("Used fallback batch number", batch_number=batch_data["batch_number"], error=str(e))
# Set default values # Set default values
if "status" not in batch_data: if "status" not in batch_data:
@@ -314,33 +323,57 @@ class ProductionBatchRepository(ProductionBaseRepository):
logger.error("Error fetching urgent batches", error=str(e)) logger.error("Error fetching urgent batches", error=str(e))
raise DatabaseError(f"Failed to fetch urgent batches: {str(e)}") raise DatabaseError(f"Failed to fetch urgent batches: {str(e)}")
async def _generate_batch_number(self, tenant_id: str) -> str: async def get_daily_batch_count(
"""Generate a unique batch number""" self,
tenant_id: str,
date_start: datetime,
date_end: datetime,
prefix: Optional[str] = None
) -> int:
"""Get the count of production batches created today for the given tenant"""
try: try:
# Get current date for prefix conditions = {
today = datetime.utcnow().date()
date_prefix = today.strftime("%Y%m%d")
# Count batches created today
today_start = datetime.combine(today, datetime.min.time())
today_end = datetime.combine(today, datetime.max.time())
daily_batches = await self.get_multi(
filters={
"tenant_id": tenant_id, "tenant_id": tenant_id,
"created_at__gte": today_start, "created_at__gte": date_start,
"created_at__lte": today_end "created_at__lte": date_end
} }
if prefix:
# Filter by batch numbers that start with the given prefix
filters_list = [
and_(
ProductionBatch.tenant_id == tenant_id,
ProductionBatch.created_at >= date_start,
ProductionBatch.created_at <= date_end,
ProductionBatch.batch_number.like(f"{prefix}-%")
)
]
result = await self.session.execute(
select(func.count(ProductionBatch.id)).where(and_(*filters_list))
)
else:
batches = await self.get_multi(filters=conditions)
result_count = len(batches)
return result_count
count = result.scalar() or 0
logger.debug(
"Retrieved daily production batch count",
tenant_id=tenant_id,
prefix=prefix,
count=count,
date_start=date_start,
date_end=date_end
) )
# Generate sequential number return count
sequence = len(daily_batches) + 1
batch_number = f"PROD-{date_prefix}-{sequence:03d}"
return batch_number
except Exception as e: except Exception as e:
logger.error("Error generating batch number", error=str(e)) logger.error(
# Fallback to timestamp-based number "Failed to get daily production batch count",
timestamp = int(datetime.utcnow().timestamp()) error=str(e),
return f"PROD-{timestamp}" tenant_id=tenant_id,
prefix=prefix
)
raise

View File

@@ -0,0 +1,110 @@
"""
Shared batch number generator utility
"""
from datetime import datetime
from typing import Optional, Protocol, Dict, Any
from sqlalchemy.ext.asyncio import AsyncSession
import structlog
logger = structlog.get_logger()
class BatchCountProvider(Protocol):
"""Protocol for providing batch counts for a specific tenant and date range"""
async def get_daily_batch_count(
self,
tenant_id: str,
date_start: datetime,
date_end: datetime,
prefix: Optional[str] = None
) -> int:
"""Get the count of batches created today for the given tenant"""
...
class BatchNumberGenerator:
"""Generates unique batch numbers across different services"""
def __init__(self, batch_provider: BatchCountProvider):
self.batch_provider = batch_provider
async def generate_batch_number(
self,
tenant_id: str,
prefix: str = "BATCH",
date: Optional[datetime] = None
) -> str:
"""
Generate a unique batch number with format: {PREFIX}-{YYYYMMDD}-{XXX}
Args:
tenant_id: The tenant ID
prefix: Prefix for the batch number (e.g., "INV", "PROD", "BATCH")
date: Date to use for the batch number (defaults to today)
Returns:
Unique batch number string
"""
try:
# Use provided date or current date
target_date = date or datetime.utcnow()
date_prefix = target_date.strftime("%Y%m%d")
# Calculate date range for the day
today_start = datetime.combine(target_date.date(), datetime.min.time())
today_end = datetime.combine(target_date.date(), datetime.max.time())
# Get count of batches created today with this prefix
daily_count = await self.batch_provider.get_daily_batch_count(
tenant_id=tenant_id,
date_start=today_start,
date_end=today_end,
prefix=prefix
)
# Generate sequential number (starting from 1)
sequence = daily_count + 1
batch_number = f"{prefix}-{date_prefix}-{sequence:03d}"
logger.info(
"Generated batch number",
tenant_id=tenant_id,
prefix=prefix,
date=target_date.date(),
sequence=sequence,
batch_number=batch_number
)
return batch_number
except Exception as e:
logger.error(
"Failed to generate batch number",
tenant_id=tenant_id,
prefix=prefix,
error=str(e)
)
raise
def create_fallback_batch_number(
prefix: str = "BATCH",
date: Optional[datetime] = None,
sequence: int = 1
) -> str:
"""
Create a fallback batch number when database access fails
Args:
prefix: Prefix for the batch number
date: Date to use (defaults to now)
sequence: Sequence number to use
Returns:
Fallback batch number string
"""
target_date = date or datetime.utcnow()
date_prefix = target_date.strftime("%Y%m%d")
return f"{prefix}-{date_prefix}-{sequence:03d}"