Improve the UI add button

This commit is contained in:
Urtzi Alfaro
2025-11-16 22:13:52 +01:00
parent 54b7a5e080
commit d36f2ab9af
23 changed files with 2047 additions and 1740 deletions

View File

@@ -4,7 +4,6 @@ import {
Edit3,
Upload,
CheckCircle2,
AlertCircle,
Download,
FileSpreadsheet,
Calendar,
@@ -23,24 +22,18 @@ import { showToast } from '../../../../utils/toast';
// STEP 1: Entry Method Selection
// ========================================
interface EntryMethodStepProps extends WizardStepProps {
data: Record<string, any>;
onDataChange: (data: Record<string, any>) => void;
}
const EntryMethodStep: React.FC<EntryMethodStepProps> = ({ data, onDataChange, onNext }) => {
const EntryMethodStep: React.FC<WizardStepProps> = ({ dataRef, onDataChange, onNext }) => {
const data = dataRef?.current || {};
const [selectedMethod, setSelectedMethod] = useState<'manual' | 'upload'>(
data.entryMethod || 'manual'
);
const handleSelect = (method: 'manual' | 'upload') => {
setSelectedMethod(method);
onDataChange({ ...data, entryMethod: method });
};
const handleContinue = () => {
onDataChange({ ...data, entryMethod: selectedMethod });
onNext();
const newData = { ...data, entryMethod: method };
onDataChange?.(newData);
// Automatically advance to next step after selection
setTimeout(() => onNext?.(), 100);
};
return (
@@ -166,17 +159,6 @@ const EntryMethodStep: React.FC<EntryMethodStepProps> = ({ data, onDataChange, o
</div>
</button>
</div>
{/* Continue Button */}
<div className="flex justify-end pt-4 border-t border-[var(--border-primary)]">
<button
onClick={handleContinue}
className="px-6 py-3 bg-[var(--color-primary)] text-white rounded-lg hover:bg-[var(--color-primary)]/90 transition-colors font-medium inline-flex items-center gap-2"
>
Continuar
<CheckCircle2 className="w-5 h-5" />
</button>
</div>
</div>
);
};
@@ -185,14 +167,9 @@ const EntryMethodStep: React.FC<EntryMethodStepProps> = ({ data, onDataChange, o
// STEP 2a: Manual Entry Form
// ========================================
const ManualEntryStep: React.FC<EntryMethodStepProps> = ({ data, onDataChange, onNext }) => {
const ManualEntryStep: React.FC<WizardStepProps> = ({ dataRef, onDataChange, onNext }) => {
const data = dataRef?.current || {};
const { currentTenant } = useTenant();
const [salesItems, setSalesItems] = useState(data.salesItems || []);
const [saleDate, setSaleDate] = useState(
data.saleDate || new Date().toISOString().split('T')[0]
);
const [paymentMethod, setPaymentMethod] = useState(data.paymentMethod || 'cash');
const [notes, setNotes] = useState(data.notes || '');
const [products, setProducts] = useState<any[]>([]);
const [loadingProducts, setLoadingProducts] = useState(true);
const [error, setError] = useState<string | null>(null);
@@ -219,14 +196,15 @@ const ManualEntryStep: React.FC<EntryMethodStepProps> = ({ data, onDataChange, o
};
const handleAddItem = () => {
setSalesItems([
...salesItems,
const newItems = [
...(data.salesItems || []),
{ id: Date.now(), productId: '', product: '', quantity: 1, unitPrice: 0, subtotal: 0 },
]);
];
onDataChange?.({ ...data, salesItems: newItems });
};
const handleUpdateItem = (index: number, field: string, value: any) => {
const updated = salesItems.map((item: any, i: number) => {
const updated = (data.salesItems || []).map((item: any, i: number) => {
if (i === index) {
const newItem = { ...item, [field]: value };
@@ -247,28 +225,25 @@ const ManualEntryStep: React.FC<EntryMethodStepProps> = ({ data, onDataChange, o
}
return item;
});
setSalesItems(updated);
onDataChange?.({ ...data, salesItems: updated });
};
const handleRemoveItem = (index: number) => {
setSalesItems(salesItems.filter((_: any, i: number) => i !== index));
const newItems = (data.salesItems || []).filter((_: any, i: number) => i !== index);
onDataChange?.({ ...data, salesItems: newItems });
};
const calculateTotal = () => {
return salesItems.reduce((sum: number, item: any) => sum + (item.subtotal || 0), 0);
return (data.salesItems || []).reduce((sum: number, item: any) => sum + (item.subtotal || 0), 0);
};
const handleSave = () => {
onDataChange({
// Auto-save totalAmount when items change
useEffect(() => {
onDataChange?.({
...data,
salesItems,
saleDate,
paymentMethod,
notes,
totalAmount: calculateTotal(),
});
onNext();
};
}, [data.salesItems]);
return (
<div className="space-y-6">
@@ -290,8 +265,8 @@ const ManualEntryStep: React.FC<EntryMethodStepProps> = ({ data, onDataChange, o
</label>
<input
type="date"
value={saleDate}
onChange={(e) => setSaleDate(e.target.value)}
value={data.saleDate || new Date().toISOString().split('T')[0]}
onChange={(e) => onDataChange?.({ ...data, saleDate: e.target.value })}
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)]"
/>
</div>
@@ -302,8 +277,8 @@ const ManualEntryStep: React.FC<EntryMethodStepProps> = ({ data, onDataChange, o
Método de Pago *
</label>
<select
value={paymentMethod}
onChange={(e) => setPaymentMethod(e.target.value)}
value={data.paymentMethod || 'cash'}
onChange={(e) => onDataChange?.({ ...data, paymentMethod: e.target.value })}
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)]"
>
<option value="cash">Efectivo</option>
@@ -349,7 +324,7 @@ const ManualEntryStep: React.FC<EntryMethodStepProps> = ({ data, onDataChange, o
<p>No hay productos terminados disponibles</p>
<p className="text-sm">Agrega productos al inventario primero</p>
</div>
) : salesItems.length === 0 ? (
) : (data.salesItems || []).length === 0 ? (
<div className="text-center py-8 border-2 border-dashed border-[var(--border-secondary)] rounded-lg text-[var(--text-tertiary)]">
<Package className="w-8 h-8 mx-auto mb-2 opacity-50" />
<p>No hay productos agregados</p>
@@ -357,7 +332,7 @@ const ManualEntryStep: React.FC<EntryMethodStepProps> = ({ data, onDataChange, o
</div>
) : (
<div className="space-y-2">
{salesItems.map((item: any, index: number) => (
{(data.salesItems || []).map((item: any, index: number) => (
<div
key={item.id}
className="p-3 border border-[var(--border-secondary)] rounded-lg bg-[var(--bg-secondary)]/30"
@@ -421,7 +396,7 @@ const ManualEntryStep: React.FC<EntryMethodStepProps> = ({ data, onDataChange, o
)}
{/* Total */}
{salesItems.length > 0 && (
{(data.salesItems || []).length > 0 && (
<div className="pt-3 border-t border-[var(--border-primary)] text-right">
<span className="text-lg font-bold text-[var(--text-primary)]">
Total: {calculateTotal().toFixed(2)}
@@ -436,24 +411,13 @@ const ManualEntryStep: React.FC<EntryMethodStepProps> = ({ data, onDataChange, o
Notas (Opcional)
</label>
<textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
value={data.notes || ''}
onChange={(e) => onDataChange?.({ ...data, notes: e.target.value })}
placeholder="Información adicional sobre esta venta..."
rows={3}
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)] text-sm"
/>
</div>
{/* Save Button */}
<div className="flex justify-end pt-4 border-t border-[var(--border-primary)]">
<button
onClick={handleSave}
disabled={salesItems.length === 0}
className="px-6 py-3 bg-[var(--color-primary)] text-white rounded-lg hover:bg-[var(--color-primary)]/90 disabled:opacity-50 disabled:cursor-not-allowed transition-colors font-medium"
>
Guardar y Continuar
</button>
</div>
</div>
);
};
@@ -462,9 +426,9 @@ const ManualEntryStep: React.FC<EntryMethodStepProps> = ({ data, onDataChange, o
// STEP 2b: File Upload
// ========================================
const FileUploadStep: React.FC<EntryMethodStepProps> = ({ data, onDataChange, onNext }) => {
const FileUploadStep: React.FC<WizardStepProps> = ({ dataRef, onDataChange, onNext }) => {
const data = dataRef?.current || {};
const { currentTenant } = useTenant();
const [file, setFile] = useState<File | null>(data.uploadedFile || null);
const [validating, setValidating] = useState(false);
const [importing, setImporting] = useState(false);
const [validationResult, setValidationResult] = useState<any>(null);
@@ -474,26 +438,26 @@ const FileUploadStep: React.FC<EntryMethodStepProps> = ({ data, onDataChange, on
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const selectedFile = e.target.files?.[0];
if (selectedFile) {
setFile(selectedFile);
onDataChange?.({ ...data, uploadedFile: selectedFile });
setValidationResult(null);
setError(null);
}
};
const handleRemoveFile = () => {
setFile(null);
onDataChange?.({ ...data, uploadedFile: null });
setValidationResult(null);
setError(null);
};
const handleValidate = async () => {
if (!file || !currentTenant?.id) return;
if (!data.uploadedFile || !currentTenant?.id) return;
setValidating(true);
setError(null);
try {
const result = await salesService.validateImportFile(currentTenant.id, file);
const result = await salesService.validateImportFile(currentTenant.id, data.uploadedFile);
setValidationResult(result);
} catch (err: any) {
console.error('Error validating file:', err);
@@ -504,15 +468,15 @@ const FileUploadStep: React.FC<EntryMethodStepProps> = ({ data, onDataChange, on
};
const handleImport = async () => {
if (!file || !currentTenant?.id) return;
if (!data.uploadedFile || !currentTenant?.id) return;
setImporting(true);
setError(null);
try {
const result = await salesService.importSalesData(currentTenant.id, file, false);
onDataChange({ ...data, uploadedFile: file, importResult: result });
onNext();
const result = await salesService.importSalesData(currentTenant.id, data.uploadedFile, false);
onDataChange?.({ ...data, importResult: result });
onNext?.();
} catch (err: any) {
console.error('Error importing file:', err);
setError(err.response?.data?.detail || 'Error al importar el archivo');
@@ -583,7 +547,7 @@ const FileUploadStep: React.FC<EntryMethodStepProps> = ({ data, onDataChange, on
</div>
{/* File Upload Area */}
{!file ? (
{!data.uploadedFile ? (
<div className="border-2 border-dashed border-[var(--border-secondary)] rounded-xl p-8 text-center bg-[var(--bg-secondary)]/30">
<FileSpreadsheet className="w-16 h-16 mx-auto mb-4 text-[var(--color-primary)]/50" />
<h4 className="text-lg font-semibold text-[var(--text-primary)] mb-2">
@@ -613,9 +577,9 @@ const FileUploadStep: React.FC<EntryMethodStepProps> = ({ data, onDataChange, on
<div className="flex items-center gap-3">
<FileSpreadsheet className="w-8 h-8 text-[var(--color-primary)]" />
<div>
<p className="font-medium text-[var(--text-primary)]">{file.name}</p>
<p className="font-medium text-[var(--text-primary)]">{data.uploadedFile.name}</p>
<p className="text-sm text-[var(--text-secondary)]">
{(file.size / 1024).toFixed(2)} KB
{(data.uploadedFile.size / 1024).toFixed(2)} KB
</p>
</div>
</div>
@@ -701,53 +665,8 @@ const FileUploadStep: React.FC<EntryMethodStepProps> = ({ data, onDataChange, on
// STEP 3: Review & Confirm
// ========================================
const ReviewStep: React.FC<EntryMethodStepProps> = ({ data, onComplete }) => {
const { currentTenant } = useTenant();
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleConfirm = async () => {
if (!currentTenant?.id) {
setError('No se pudo obtener información del tenant');
return;
}
setLoading(true);
setError(null);
try {
if (data.entryMethod === 'manual' && data.salesItems) {
// Create individual sales records for each item
for (const item of data.salesItems) {
const salesData = {
inventory_product_id: item.productId || null, // Include inventory product ID for stock tracking
product_name: item.product,
product_category: 'general', // Could be enhanced with category selection
quantity_sold: item.quantity,
unit_price: item.unitPrice,
total_amount: item.subtotal,
sale_date: data.saleDate,
sales_channel: 'retail',
source: 'manual',
payment_method: data.paymentMethod,
notes: data.notes,
};
await salesService.createSalesRecord(currentTenant.id, salesData);
}
}
showToast.success('Registro de ventas guardado exitosamente');
onComplete();
} catch (err: any) {
console.error('Error saving sales data:', err);
const errorMessage = err.response?.data?.detail || 'Error al guardar los datos de ventas';
setError(errorMessage);
showToast.error(errorMessage);
} finally {
setLoading(false);
}
};
const ReviewStep: React.FC<WizardStepProps> = ({ dataRef }) => {
const data = dataRef?.current || {};
const isManual = data.entryMethod === 'manual';
const isUpload = data.entryMethod === 'upload';
@@ -768,13 +687,6 @@ const ReviewStep: React.FC<EntryMethodStepProps> = ({ data, onComplete }) => {
</p>
</div>
{error && (
<div className="p-3 bg-red-50 border border-red-200 rounded-lg text-red-700 text-sm flex items-start gap-2">
<AlertCircle className="w-4 h-4 mt-0.5 flex-shrink-0" />
<span>{error}</span>
</div>
)}
{isManual && data.salesItems && (
<div className="space-y-4">
{/* Summary */}
@@ -796,10 +708,10 @@ const ReviewStep: React.FC<EntryMethodStepProps> = ({ data, onComplete }) => {
{/* Items */}
<div>
<h4 className="text-sm font-semibold text-[var(--text-secondary)] mb-2">
Productos ({data.salesItems.length})
Productos ({(data.salesItems || []).length})
</h4>
<div className="space-y-2">
{data.salesItems.map((item: any) => (
{(data.salesItems || []).map((item: any) => (
<div
key={item.id}
className="p-3 border border-[var(--border-secondary)] rounded-lg bg-[var(--bg-primary)] flex justify-between items-center"
@@ -853,27 +765,6 @@ const ReviewStep: React.FC<EntryMethodStepProps> = ({ data, onComplete }) => {
</div>
</div>
)}
{/* Confirm Button */}
<div className="flex justify-end gap-3 pt-4 border-t border-[var(--border-primary)]">
<button
onClick={handleConfirm}
disabled={loading || (isUpload && !data.importResult)}
className="px-8 py-3 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors font-semibold inline-flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? (
<>
<Loader2 className="w-5 h-5 animate-spin" />
Guardando...
</>
) : (
<>
<CheckCircle2 className="w-5 h-5" />
Confirmar y Guardar
</>
)}
</button>
</div>
</div>
);
};
@@ -883,20 +774,19 @@ const ReviewStep: React.FC<EntryMethodStepProps> = ({ data, onComplete }) => {
// ========================================
export const SalesEntryWizardSteps = (
data: Record<string, any>,
dataRef: React.MutableRefObject<Record<string, any>>,
setData: (data: Record<string, any>) => void
): WizardStep[] => {
const entryMethod = data.entryMethod;
const entryMethod = dataRef.current.entryMethod;
// Dynamic steps based on entry method
// New architecture: return direct component references instead of arrow functions
// dataRef and onDataChange are now passed through WizardModal props
const steps: WizardStep[] = [
{
id: 'entry-method',
title: 'Método de Entrada',
description: 'Elige cómo registrar las ventas',
component: (props) => (
<EntryMethodStep {...props} data={data} onDataChange={setData} />
),
component: EntryMethodStep,
},
];
@@ -905,14 +795,18 @@ export const SalesEntryWizardSteps = (
id: 'manual-entry',
title: 'Ingresar Datos',
description: 'Registra los detalles de la venta',
component: (props) => <ManualEntryStep {...props} data={data} onDataChange={setData} />,
component: ManualEntryStep,
validate: () => {
const data = dataRef.current;
return (data.salesItems || []).length > 0;
},
});
} else if (entryMethod === 'upload') {
steps.push({
id: 'file-upload',
title: 'Cargar Archivo',
description: 'Importa ventas desde archivo',
component: (props) => <FileUploadStep {...props} data={data} onDataChange={setData} />,
component: FileUploadStep,
});
}
@@ -920,7 +814,51 @@ export const SalesEntryWizardSteps = (
id: 'review',
title: 'Revisar',
description: 'Confirma los datos antes de guardar',
component: (props) => <ReviewStep {...props} data={data} onDataChange={setData} />,
component: ReviewStep,
validate: async () => {
const { useTenant } = await import('../../../../stores/tenant.store');
const { salesService } = await import('../../../../api/services/sales');
const { showToast } = await import('../../../../utils/toast');
const data = dataRef.current;
const { currentTenant } = useTenant.getState();
if (!currentTenant?.id) {
showToast.error('No se pudo obtener información del tenant');
return false;
}
try {
if (data.entryMethod === 'manual' && data.salesItems) {
// Create individual sales records for each item
for (const item of data.salesItems) {
const salesData = {
inventory_product_id: item.productId || null,
product_name: item.product,
product_category: 'general',
quantity_sold: item.quantity,
unit_price: item.unitPrice,
total_amount: item.subtotal,
sale_date: data.saleDate,
sales_channel: 'retail',
source: 'manual',
payment_method: data.paymentMethod,
notes: data.notes,
};
await salesService.createSalesRecord(currentTenant.id, salesData);
}
}
showToast.success('Registro de ventas guardado exitosamente');
return true;
} catch (err: any) {
console.error('Error saving sales data:', err);
const errorMessage = err.response?.data?.detail || 'Error al guardar los datos de ventas';
showToast.error(errorMessage);
return false;
}
},
});
return steps;