erp-core-frontend-v2/src/pages/sales/QuotationEditPage.tsx
Adrian Flores Cortes 36ba16e2a2 feat(sales): complete Sales and Quotations module CRUD pages
Sales Orders:
- SalesOrderCreatePage: Full form with line items, validation
- SalesOrderDetailPage: View with status-based actions
- SalesOrderEditPage: Edit draft orders with line item management

Quotations:
- QuotationCreatePage: Full form with line items
- QuotationDetailPage: View with send/accept/reject/convert actions
- QuotationEditPage: Edit draft quotations

Components:
- SalesOrderForm, SalesOrderLineItems, SalesOrderLineItemForm
- SalesOrderSummary, SalesOrderStatusBadge
- QuotationStatusBadge, QuotationActions

Routes configured for:
- /sales/orders, /sales/orders/new, /sales/orders/:id, /sales/orders/:id/edit
- /sales/quotations, /sales/quotations/new, /sales/quotations/:id, /sales/quotations/:id/edit

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 19:51:13 -06:00

781 lines
29 KiB
TypeScript

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<FormData>({
partnerId: '',
quotationDate: '',
validityDate: '',
currencyId: '',
notes: '',
termsConditions: '',
});
const [lineItems, setLineItems] = useState<LineItemFormData[]>([]);
const [errors, setErrors] = useState<FormErrors>({});
const [isSubmitting, setIsSubmitting] = useState(false);
const [submitError, setSubmitError] = useState<string | null>(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 (
<div className="flex h-96 items-center justify-center">
<Spinner size="lg" />
</div>
);
}
// Error state
if (quotationError || !quotation) {
return (
<div className="p-6">
<ErrorEmptyState
title="Cotizacion no encontrada"
description="No se pudo cargar la informacion de la cotizacion."
onRetry={refresh}
/>
</div>
);
}
const totals = calculateTotals();
return (
<div className="space-y-6 p-6">
<Breadcrumbs
items={[
{ label: 'Ventas', href: '/sales' },
{ label: 'Cotizaciones', href: '/sales/quotations' },
{ label: quotation.quotationNumber, href: `/sales/quotations/${id}` },
{ label: 'Editar' },
]}
/>
<div className="flex items-center gap-4">
<Button variant="ghost" size="sm" onClick={() => navigate(`/sales/quotations/${id}`)}>
<ArrowLeft className="mr-2 h-4 w-4" />
Volver
</Button>
<div>
<h1 className="text-2xl font-bold text-gray-900">
Editar Cotizacion {quotation.quotationNumber}
</h1>
<p className="text-sm text-gray-500">
Modifica la informacion de la cotizacion
</p>
</div>
</div>
<form onSubmit={handleSubmit}>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
{/* Main Content */}
<div className="lg:col-span-2 space-y-6">
{/* Customer Information */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<User className="h-5 w-5" />
Informacion del Cliente
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Cliente *
</label>
<select
value={formData.partnerId}
onChange={(e) => handleFieldChange('partnerId', e.target.value)}
className={`w-full rounded-md border px-3 py-2 focus:outline-none focus:ring-1 ${
errors.partnerId
? 'border-red-300 focus:border-red-500 focus:ring-red-500'
: 'border-gray-300 focus:border-blue-500 focus:ring-blue-500'
}`}
disabled={partnersLoading}
>
<option value="">Seleccionar cliente...</option>
{partners.map((partner) => (
<option key={partner.id} value={partner.id}>
{partner.name}
</option>
))}
</select>
{errors.partnerId && (
<p className="mt-1 text-sm text-red-600">{errors.partnerId}</p>
)}
</div>
</CardContent>
</Card>
{/* Quotation Details */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Calendar className="h-5 w-5" />
Detalles de la Cotizacion
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Fecha de Cotizacion *
</label>
<input
type="date"
value={formData.quotationDate}
onChange={(e) => 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 && (
<p className="mt-1 text-sm text-red-600">{errors.quotationDate}</p>
)}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Vigencia Hasta
</label>
<input
type="date"
value={formData.validityDate}
onChange={(e) => 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"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Moneda *
</label>
<select
value={formData.currencyId}
onChange={(e) => handleFieldChange('currencyId', e.target.value)}
className={`w-full rounded-md border px-3 py-2 focus:outline-none focus:ring-1 ${
errors.currencyId
? 'border-red-300 focus:border-red-500 focus:ring-red-500'
: 'border-gray-300 focus:border-blue-500 focus:ring-blue-500'
}`}
>
<option value="">Seleccionar moneda...</option>
<option value="MXN">MXN - Peso Mexicano</option>
<option value="USD">USD - Dolar Americano</option>
<option value="EUR">EUR - Euro</option>
</select>
{errors.currencyId && (
<p className="mt-1 text-sm text-red-600">{errors.currencyId}</p>
)}
</div>
</div>
</CardContent>
</Card>
{/* Line Items */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="flex items-center gap-2">
<Package className="h-5 w-5" />
Lineas de la Cotizacion
</CardTitle>
<Button type="button" variant="outline" size="sm" onClick={addNewLine}>
<Plus className="mr-1 h-4 w-4" />
Agregar Linea
</Button>
</div>
</CardHeader>
<CardContent>
{errors.lines && (
<Alert variant="danger" className="mb-4">
<AlertCircle className="h-4 w-4" />
{errors.lines}
</Alert>
)}
{lineItems.length === 0 ? (
<div className="text-center py-8 text-gray-500">
<Package className="mx-auto h-12 w-12 text-gray-300" />
<p className="mt-2">No hay lineas en la cotizacion</p>
<Button
type="button"
variant="outline"
size="sm"
className="mt-4"
onClick={addNewLine}
>
<Plus className="mr-1 h-4 w-4" />
Agregar primera linea
</Button>
</div>
) : (
<div className="space-y-4">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-gray-500">
<th className="pb-2 font-medium">Producto</th>
<th className="pb-2 font-medium">Descripcion</th>
<th className="pb-2 font-medium text-right">Cantidad</th>
<th className="pb-2 font-medium text-right">Precio</th>
<th className="pb-2 font-medium text-right">Desc %</th>
<th className="pb-2 font-medium text-right">Total</th>
<th className="pb-2 font-medium"></th>
</tr>
</thead>
<tbody>
{lineItems.map((line, index) => (
<tr
key={line.id || `new-${index}`}
className={`border-b ${line.isDeleted ? 'bg-red-50 opacity-50' : ''}`}
>
<td className="py-2">
<input
type="text"
value={line.productName || line.productId}
onChange={(e) => 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"
/>
</td>
<td className="py-2">
<input
type="text"
value={line.description}
onChange={(e) => 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"
/>
</td>
<td className="py-2">
<input
type="number"
value={line.quantity}
onChange={(e) => 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"
/>
</td>
<td className="py-2">
<input
type="number"
value={line.priceUnit}
onChange={(e) => 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"
/>
</td>
<td className="py-2">
<input
type="number"
value={line.discount}
onChange={(e) => 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"
/>
</td>
<td className="py-2 text-right font-medium">
${formatCurrency(calculateLineTotal(line))}
</td>
<td className="py-2 pl-2">
{line.isDeleted ? (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => restoreLine(index)}
className="text-blue-600 hover:text-blue-700"
>
Restaurar
</Button>
) : (
<button
type="button"
onClick={() => markLineForDeletion(index)}
className="rounded p-1 text-gray-400 hover:bg-red-50 hover:text-red-600"
>
<Trash2 className="h-4 w-4" />
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</CardContent>
</Card>
{/* Notes */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<FileText className="h-5 w-5" />
Notas y Terminos
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Notas
</label>
<textarea
value={formData.notes}
onChange={(e) => handleFieldChange('notes', e.target.value)}
rows={3}
placeholder="Notas internas sobre la cotizacion..."
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"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Terminos y Condiciones
</label>
<textarea
value={formData.termsConditions}
onChange={(e) => handleFieldChange('termsConditions', e.target.value)}
rows={3}
placeholder="Terminos y condiciones para el cliente..."
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"
/>
</div>
</CardContent>
</Card>
</div>
{/* Sidebar - Summary */}
<div className="space-y-6">
{/* Submit Error */}
{submitError && (
<Alert
variant="danger"
title="Error al guardar"
onClose={() => setSubmitError(null)}
>
{submitError}
</Alert>
)}
{/* Quotation Summary */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<FileText className="h-5 w-5" />
Resumen
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-3">
<div className="flex justify-between text-sm">
<span className="text-gray-500">Subtotal</span>
<span>${formatCurrency(totals.subtotal)}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-500">IVA (16%)</span>
<span>${formatCurrency(totals.tax)}</span>
</div>
<div className="border-t pt-3">
<div className="flex justify-between font-semibold">
<span>Total</span>
<span className="text-lg">${formatCurrency(totals.total)}</span>
</div>
</div>
<div className="text-xs text-gray-400">
{lineItems.filter((l) => !l.isDeleted).length} linea(s) activa(s)
</div>
</div>
</CardContent>
</Card>
{/* Actions */}
<Card>
<CardContent className="pt-6">
<div className="space-y-3">
<Button
type="submit"
className="w-full"
disabled={isSubmitting || !hasChanges}
>
{isSubmitting ? (
<>
<Spinner size="sm" className="mr-2" />
Guardando...
</>
) : (
<>
<Save className="mr-2 h-4 w-4" />
Guardar Cambios
</>
)}
</Button>
<Button
type="button"
variant="outline"
className="w-full"
onClick={() => navigate(`/sales/quotations/${id}`)}
disabled={isSubmitting}
>
Cancelar
</Button>
</div>
{!hasChanges && (
<p className="mt-3 text-center text-xs text-gray-400">
No hay cambios para guardar
</p>
)}
</CardContent>
</Card>
{/* Status Info */}
<Card>
<CardContent className="pt-6">
<div className="text-center">
<span className="inline-flex items-center rounded-full bg-gray-100 px-3 py-1 text-sm font-medium text-gray-700">
Estado: Borrador
</span>
<p className="mt-2 text-xs text-gray-500">
Solo las cotizaciones en estado borrador pueden ser editadas
</p>
</div>
</CardContent>
</Card>
</div>
</div>
</form>
</div>
);
}
export default QuotationEditPage;