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>
781 lines
29 KiB
TypeScript
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;
|