import { useState, useEffect } from 'react'; import { useNavigate, useParams } from 'react-router-dom'; import { ArrowLeft, Save, Plus, Trash2, FileText, User, Calendar, Package, AlertCircle, } from 'lucide-react'; import { Button } from '@components/atoms/Button'; import { Spinner } from '@components/atoms/Spinner'; import { Card, CardHeader, CardTitle, CardContent } from '@components/molecules/Card'; import { Alert } from '@components/molecules/Alert'; import { Breadcrumbs } from '@components/organisms/Breadcrumbs'; import { useToast } from '@components/organisms/Toast'; import { ErrorEmptyState } from '@components/templates/EmptyState'; import { useQuotation } from '@features/sales/hooks'; import { salesApi } from '@features/sales'; import { usePartners } from '@features/partners/hooks'; import type { UpdateQuotationDto, CreateQuotationLineDto, UpdateQuotationLineDto } from '@features/sales/types'; import { formatNumber } from '@utils/formatters'; interface LineItemFormData { id?: string; productId: string; productName: string; description: string; quantity: number; uomId: string; uomName: string; priceUnit: number; discount: number; isNew?: boolean; isModified?: boolean; isDeleted?: boolean; } interface FormData { partnerId: string; quotationDate: string; validityDate: string; currencyId: string; notes: string; termsConditions: string; } interface FormErrors { partnerId?: string; quotationDate?: string; currencyId?: string; lines?: string; } const formatCurrency = (value: number): string => { return formatNumber(value, 'es-MX', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); }; export function QuotationEditPage() { const { id } = useParams<{ id: string }>(); const navigate = useNavigate(); const { showToast } = useToast(); // Hooks const { quotation, isLoading: quotationLoading, error: quotationError, refresh } = useQuotation(id || null); const { partners, isLoading: partnersLoading } = usePartners({ isCustomer: true, limit: 100 }); // Form state const [formData, setFormData] = useState({ partnerId: '', quotationDate: '', validityDate: '', currencyId: '', notes: '', termsConditions: '', }); const [lineItems, setLineItems] = useState([]); const [errors, setErrors] = useState({}); const [isSubmitting, setIsSubmitting] = useState(false); const [submitError, setSubmitError] = useState(null); const [hasChanges, setHasChanges] = useState(false); // Populate form when quotation loads useEffect(() => { if (quotation) { const qDate = String(quotation.quotationDate || ''); const vDate = String(quotation.validityDate || ''); setFormData({ partnerId: String(quotation.partnerId || ''), quotationDate: qDate.split('T')[0] ?? qDate, validityDate: vDate.split('T')[0] ?? vDate, currencyId: String(quotation.currencyId || ''), notes: String(quotation.notes || ''), termsConditions: String(quotation.termsConditions || ''), }); // Load existing lines if (quotation.lines) { setLineItems( quotation.lines.map((line) => ({ id: line.id, productId: line.productId, productName: line.productName || '', description: line.description, quantity: line.quantity, uomId: line.uomId, uomName: line.uomName || '', priceUnit: line.priceUnit, discount: line.discount, isNew: false, isModified: false, isDeleted: false, })) ); } } }, [quotation]); // Check if quotation is editable (only draft quotations can be edited) const isEditable = quotation?.status === 'draft'; // Redirect if not editable useEffect(() => { if (quotation && !isEditable) { showToast({ type: 'warning', title: 'No editable', message: `La cotizacion ${quotation.quotationNumber} no puede ser editada porque su estado es "${quotation.status}".`, }); navigate(`/sales/quotations/${id}`); } }, [quotation, isEditable, navigate, id, showToast]); const handleFieldChange = (field: keyof FormData, value: string) => { setFormData((prev) => ({ ...prev, [field]: value })); setHasChanges(true); if (errors[field as keyof FormErrors]) { setErrors((prev) => ({ ...prev, [field]: undefined })); } }; const handleLineChange = (index: number, field: keyof LineItemFormData, value: string | number) => { setLineItems((prev) => { const updated = [...prev]; const currentLine = updated[index]; if (currentLine) { updated[index] = { ...currentLine, [field]: value, isModified: !currentLine.isNew, }; } return updated; }); setHasChanges(true); }; const addNewLine = () => { setLineItems((prev) => [ ...prev, { productId: '', productName: '', description: '', quantity: 1, uomId: '', uomName: '', priceUnit: 0, discount: 0, isNew: true, isModified: false, isDeleted: false, }, ]); setHasChanges(true); }; const markLineForDeletion = (index: number) => { setLineItems((prev) => { const updated = [...prev]; const currentLine = updated[index]; if (currentLine?.isNew) { // Remove new lines immediately return updated.filter((_, i) => i !== index); } // Mark existing lines for deletion if (currentLine) { updated[index] = { ...currentLine, isDeleted: true }; } return updated; }); setHasChanges(true); }; const restoreLine = (index: number) => { setLineItems((prev) => { const updated = [...prev]; const currentLine = updated[index]; if (currentLine) { updated[index] = { ...currentLine, isDeleted: false }; } return updated; }); }; const calculateLineTotal = (line: LineItemFormData): number => { const subtotal = line.quantity * line.priceUnit; const discountAmount = subtotal * (line.discount / 100); return subtotal - discountAmount; }; const calculateTotals = () => { const activeLines = lineItems.filter((l) => !l.isDeleted); const subtotal = activeLines.reduce((sum, line) => sum + calculateLineTotal(line), 0); // For simplicity, estimate tax at 16% (IVA Mexico) const taxRate = 0.16; const tax = subtotal * taxRate; const total = subtotal + tax; return { subtotal, tax, total }; }; const validateForm = (): boolean => { const newErrors: FormErrors = {}; if (!formData.partnerId) { newErrors.partnerId = 'El cliente es requerido'; } if (!formData.quotationDate) { newErrors.quotationDate = 'La fecha de cotizacion es requerida'; } if (!formData.currencyId) { newErrors.currencyId = 'La moneda es requerida'; } const activeLines = lineItems.filter((l) => !l.isDeleted); if (activeLines.length === 0) { newErrors.lines = 'Debe agregar al menos una linea de cotizacion'; } else { const invalidLine = activeLines.find( (l) => !l.productId || l.quantity <= 0 || l.priceUnit < 0 ); if (invalidLine) { newErrors.lines = 'Todas las lineas deben tener producto, cantidad y precio validos'; } } setErrors(newErrors); return Object.keys(newErrors).length === 0; }; const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); if (!validateForm() || !id) return; setIsSubmitting(true); setSubmitError(null); try { // 1. Update quotation header const quotationUpdate: UpdateQuotationDto = { partnerId: formData.partnerId, quotationDate: formData.quotationDate, validityDate: formData.validityDate || null, currencyId: formData.currencyId, notes: formData.notes || null, termsConditions: formData.termsConditions || null, }; await salesApi.updateQuotation(id, quotationUpdate); // 2. Process line items const linesToDelete = lineItems.filter((l) => l.isDeleted && l.id); const linesToUpdate = lineItems.filter((l) => l.isModified && l.id && !l.isDeleted); const linesToAdd = lineItems.filter((l) => l.isNew && !l.isDeleted); // Delete lines for (const line of linesToDelete) { if (line.id) { await salesApi.removeQuotationLine(id, line.id); } } // Update existing lines for (const line of linesToUpdate) { if (line.id) { const updateData: UpdateQuotationLineDto = { description: line.description, quantity: line.quantity, uomId: line.uomId, priceUnit: line.priceUnit, discount: line.discount, }; await salesApi.updateQuotationLine(id, line.id, updateData); } } // Add new lines for (const line of linesToAdd) { const createData: CreateQuotationLineDto = { productId: line.productId, description: line.description, quantity: line.quantity, uomId: line.uomId, priceUnit: line.priceUnit, discount: line.discount, }; await salesApi.addQuotationLine(id, createData); } showToast({ type: 'success', title: 'Cotizacion actualizada', message: 'Los cambios han sido guardados exitosamente.', }); navigate(`/sales/quotations/${id}`); } catch (err) { const message = err instanceof Error ? err.message : 'Error al actualizar la cotizacion'; setSubmitError(message); showToast({ type: 'error', title: 'Error', message, }); } finally { setIsSubmitting(false); } }; // Loading state if (quotationLoading) { return (
); } // Error state if (quotationError || !quotation) { return (
); } const totals = calculateTotals(); return (

Editar Cotizacion {quotation.quotationNumber}

Modifica la informacion de la cotizacion

{/* Main Content */}
{/* Customer Information */} Informacion del Cliente
{errors.partnerId && (

{errors.partnerId}

)}
{/* Quotation Details */} Detalles de la Cotizacion
handleFieldChange('quotationDate', e.target.value)} className={`w-full rounded-md border px-3 py-2 focus:outline-none focus:ring-1 ${ errors.quotationDate ? 'border-red-300 focus:border-red-500 focus:ring-red-500' : 'border-gray-300 focus:border-blue-500 focus:ring-blue-500' }`} /> {errors.quotationDate && (

{errors.quotationDate}

)}
handleFieldChange('validityDate', e.target.value)} className="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500" />
{errors.currencyId && (

{errors.currencyId}

)}
{/* Line Items */}
Lineas de la Cotizacion
{errors.lines && ( {errors.lines} )} {lineItems.length === 0 ? (

No hay lineas en la cotizacion

) : (
{lineItems.map((line, index) => ( ))}
Producto Descripcion Cantidad Precio Desc % Total
handleLineChange(index, 'productId', e.target.value)} placeholder="ID Producto" disabled={line.isDeleted} className="w-24 rounded border border-gray-300 px-2 py-1 text-sm focus:border-blue-500 focus:outline-none disabled:bg-gray-100" /> handleLineChange(index, 'description', e.target.value)} placeholder="Descripcion" disabled={line.isDeleted} className="w-full rounded border border-gray-300 px-2 py-1 text-sm focus:border-blue-500 focus:outline-none disabled:bg-gray-100" /> handleLineChange(index, 'quantity', parseFloat(e.target.value) || 0)} min="0" step="0.01" disabled={line.isDeleted} className="w-20 rounded border border-gray-300 px-2 py-1 text-right text-sm focus:border-blue-500 focus:outline-none disabled:bg-gray-100" /> handleLineChange(index, 'priceUnit', parseFloat(e.target.value) || 0)} min="0" step="0.01" disabled={line.isDeleted} className="w-24 rounded border border-gray-300 px-2 py-1 text-right text-sm focus:border-blue-500 focus:outline-none disabled:bg-gray-100" /> handleLineChange(index, 'discount', parseFloat(e.target.value) || 0)} min="0" max="100" step="0.01" disabled={line.isDeleted} className="w-16 rounded border border-gray-300 px-2 py-1 text-right text-sm focus:border-blue-500 focus:outline-none disabled:bg-gray-100" /> ${formatCurrency(calculateLineTotal(line))} {line.isDeleted ? ( ) : ( )}
)}
{/* Notes */} Notas y Terminos