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>
This commit is contained in:
Adrian Flores Cortes 2026-02-03 19:51:13 -06:00
parent 2b2361d87c
commit 36ba16e2a2
21 changed files with 5667 additions and 3 deletions

View File

@ -44,6 +44,18 @@ const SecuritySettingsPage = lazy(() => import('@pages/settings/SecuritySettings
const SystemSettingsPage = lazy(() => import('@pages/settings/SystemSettingsPage').then(m => ({ default: m.SystemSettingsPage })));
const AuditLogsPage = lazy(() => import('@pages/settings/AuditLogsPage').then(m => ({ default: m.AuditLogsPage })));
// Sales pages
const SalesOrdersPage = lazy(() => import('@pages/sales/SalesOrdersPage').then(m => ({ default: m.SalesOrdersPage })));
const SalesOrderCreatePage = lazy(() => import('@pages/sales/SalesOrderCreatePage').then(m => ({ default: m.SalesOrderCreatePage })));
const SalesOrderDetailPage = lazy(() => import('@pages/sales/SalesOrderDetailPage').then(m => ({ default: m.SalesOrderDetailPage })));
const SalesOrderEditPage = lazy(() => import('@pages/sales/SalesOrderEditPage').then(m => ({ default: m.SalesOrderEditPage })));
// Quotation pages
const QuotationsPage = lazy(() => import('@pages/sales/QuotationsPage').then(m => ({ default: m.QuotationsPage })));
const QuotationDetailPage = lazy(() => import('@pages/sales/QuotationDetailPage').then(m => ({ default: m.QuotationDetailPage })));
const QuotationCreatePage = lazy(() => import('@pages/sales/QuotationCreatePage').then(m => ({ default: m.QuotationCreatePage })));
const QuotationEditPage = lazy(() => import('@pages/sales/QuotationEditPage').then(m => ({ default: m.QuotationEditPage })));
// CRM pages
const PipelineKanbanPage = lazy(() => import('@pages/crm/PipelineKanbanPage').then(m => ({ default: m.PipelineKanbanPage })));
const LeadsPage = lazy(() => import('@pages/crm/LeadsPage').then(m => ({ default: m.LeadsPage })));
@ -212,11 +224,81 @@ export const router = createBrowserRouter([
</DashboardWrapper>
),
},
// Sales routes
{
path: '/sales',
element: <Navigate to="/sales/orders" replace />,
},
{
path: '/sales/orders',
element: (
<DashboardWrapper>
<SalesOrdersPage />
</DashboardWrapper>
),
},
{
path: '/sales/orders/new',
element: (
<DashboardWrapper>
<SalesOrderCreatePage />
</DashboardWrapper>
),
},
{
path: '/sales/orders/:id',
element: (
<DashboardWrapper>
<SalesOrderDetailPage />
</DashboardWrapper>
),
},
{
path: '/sales/orders/:id/edit',
element: (
<DashboardWrapper>
<SalesOrderEditPage />
</DashboardWrapper>
),
},
// Quotation routes
{
path: '/sales/quotations',
element: (
<DashboardWrapper>
<QuotationsPage />
</DashboardWrapper>
),
},
{
path: '/sales/quotations/new',
element: (
<DashboardWrapper>
<QuotationCreatePage />
</DashboardWrapper>
),
},
{
path: '/sales/quotations/:id',
element: (
<DashboardWrapper>
<QuotationDetailPage />
</DashboardWrapper>
),
},
{
path: '/sales/quotations/:id/edit',
element: (
<DashboardWrapper>
<QuotationEditPage />
</DashboardWrapper>
),
},
{
path: '/sales/*',
element: (
<DashboardWrapper>
<div className="text-center text-gray-500">Módulo de Ventas - En desarrollo</div>
<div className="text-center text-gray-500">Seccion de Ventas - En desarrollo</div>
</DashboardWrapper>
),
},

View File

@ -9,7 +9,11 @@ import type {
SalesOrderFilters,
SalesOrdersResponse,
Quotation,
QuotationLine,
CreateQuotationDto,
UpdateQuotationDto,
CreateQuotationLineDto,
UpdateQuotationLineDto,
QuotationFilters,
QuotationsResponse,
} from '../types';
@ -147,6 +151,38 @@ export const salesApi = {
return response.data;
},
// Update quotation
updateQuotation: async (id: string, data: UpdateQuotationDto): Promise<Quotation> => {
const response = await api.patch<Quotation>(`${QUOTATIONS_URL}/${id}`, data);
return response.data;
},
// Delete quotation
deleteQuotation: async (id: string): Promise<void> => {
await api.delete(`${QUOTATIONS_URL}/${id}`);
},
// ==================== Quotation Lines ====================
// Add line to quotation
addQuotationLine: async (quotationId: string, data: CreateQuotationLineDto): Promise<QuotationLine> => {
const response = await api.post<QuotationLine>(`${QUOTATIONS_URL}/${quotationId}/lines`, data);
return response.data;
},
// Update quotation line
updateQuotationLine: async (quotationId: string, lineId: string, data: UpdateQuotationLineDto): Promise<QuotationLine> => {
const response = await api.patch<QuotationLine>(`${QUOTATIONS_URL}/${quotationId}/lines/${lineId}`, data);
return response.data;
},
// Remove quotation line
removeQuotationLine: async (quotationId: string, lineId: string): Promise<void> => {
await api.delete(`${QUOTATIONS_URL}/${quotationId}/lines/${lineId}`);
},
// ==================== Quotation Actions ====================
// Send quotation
sendQuotation: async (id: string): Promise<Quotation> => {
const response = await api.post<Quotation>(`${QUOTATIONS_URL}/${id}/send`);

View File

@ -0,0 +1,287 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Edit,
Trash2,
Send,
CheckCircle,
XCircle,
ShoppingCart,
Download,
RefreshCw,
} from 'lucide-react';
import { Button } from '@components/atoms/Button';
import { ConfirmModal } from '@components/organisms/Modal';
import { useToast } from '@components/organisms/Toast';
import { salesApi } from '../api/sales.api';
import type { Quotation } from '../types';
import type { QuotationStatus } from './QuotationStatusBadge';
interface QuotationActionsProps {
quotation: Quotation;
onRefresh: () => void;
isProcessing?: boolean;
}
export function QuotationActions({ quotation, onRefresh, isProcessing = false }: QuotationActionsProps) {
const navigate = useNavigate();
const { showToast } = useToast();
const [showDeleteModal, setShowDeleteModal] = useState(false);
const [showSendModal, setShowSendModal] = useState(false);
const [showAcceptModal, setShowAcceptModal] = useState(false);
const [showRejectModal, setShowRejectModal] = useState(false);
const [showConvertModal, setShowConvertModal] = useState(false);
const [localProcessing, setLocalProcessing] = useState(false);
const status = quotation.status as QuotationStatus;
const canEdit = status === 'draft';
const canDelete = status === 'draft';
const canSend = status === 'draft';
const canAccept = status === 'sent';
const canReject = status === 'sent';
const canConvert = status === 'accepted';
const handleDelete = async () => {
setLocalProcessing(true);
try {
await salesApi.deleteQuotation(quotation.id);
showToast({
type: 'success',
title: 'Cotizacion eliminada',
message: 'La cotizacion ha sido eliminada exitosamente.',
});
navigate('/sales/quotations');
} catch {
showToast({
type: 'error',
title: 'Error',
message: 'No se pudo eliminar la cotizacion.',
});
} finally {
setLocalProcessing(false);
setShowDeleteModal(false);
}
};
const handleSend = async () => {
setLocalProcessing(true);
try {
await salesApi.sendQuotation(quotation.id);
showToast({
type: 'success',
title: 'Cotizacion enviada',
message: 'La cotizacion ha sido enviada al cliente.',
});
onRefresh();
} catch {
showToast({
type: 'error',
title: 'Error',
message: 'No se pudo enviar la cotizacion.',
});
} finally {
setLocalProcessing(false);
setShowSendModal(false);
}
};
const handleAccept = async () => {
setLocalProcessing(true);
try {
await salesApi.acceptQuotation(quotation.id);
showToast({
type: 'success',
title: 'Cotizacion aceptada',
message: 'La cotizacion ha sido marcada como aceptada.',
});
onRefresh();
} catch {
showToast({
type: 'error',
title: 'Error',
message: 'No se pudo aceptar la cotizacion.',
});
} finally {
setLocalProcessing(false);
setShowAcceptModal(false);
}
};
const handleReject = async () => {
setLocalProcessing(true);
try {
await salesApi.rejectQuotation(quotation.id);
showToast({
type: 'success',
title: 'Cotizacion rechazada',
message: 'La cotizacion ha sido marcada como rechazada.',
});
onRefresh();
} catch {
showToast({
type: 'error',
title: 'Error',
message: 'No se pudo rechazar la cotizacion.',
});
} finally {
setLocalProcessing(false);
setShowRejectModal(false);
}
};
const handleConvert = async () => {
setLocalProcessing(true);
try {
const order = await salesApi.convertToOrder(quotation.id);
showToast({
type: 'success',
title: 'Cotizacion convertida',
message: `Se ha creado el pedido de venta ${order.name}.`,
});
navigate(`/sales/orders/${order.id}`);
} catch {
showToast({
type: 'error',
title: 'Error',
message: 'No se pudo convertir la cotizacion a pedido.',
});
} finally {
setLocalProcessing(false);
setShowConvertModal(false);
}
};
const handleDownloadPdf = async () => {
try {
await salesApi.downloadQuotationPdf(quotation.id, `cotizacion-${quotation.quotationNumber}.pdf`);
} catch {
showToast({
type: 'error',
title: 'Error',
message: 'No se pudo descargar el PDF.',
});
}
};
const processing = isProcessing || localProcessing;
return (
<>
<div className="flex flex-wrap items-center gap-2">
<Button variant="outline" onClick={onRefresh} disabled={processing}>
<RefreshCw className={`mr-2 h-4 w-4 ${processing ? 'animate-spin' : ''}`} />
Actualizar
</Button>
<Button variant="outline" onClick={handleDownloadPdf} disabled={processing}>
<Download className="mr-2 h-4 w-4" />
PDF
</Button>
{canEdit && (
<Button variant="outline" onClick={() => navigate(`/sales/quotations/${quotation.id}/edit`)}>
<Edit className="mr-2 h-4 w-4" />
Editar
</Button>
)}
{canSend && (
<Button variant="primary" onClick={() => setShowSendModal(true)}>
<Send className="mr-2 h-4 w-4" />
Enviar
</Button>
)}
{canAccept && (
<Button variant="primary" onClick={() => setShowAcceptModal(true)}>
<CheckCircle className="mr-2 h-4 w-4" />
Aceptar
</Button>
)}
{canReject && (
<Button variant="outline" onClick={() => setShowRejectModal(true)}>
<XCircle className="mr-2 h-4 w-4" />
Rechazar
</Button>
)}
{canConvert && (
<Button onClick={() => setShowConvertModal(true)}>
<ShoppingCart className="mr-2 h-4 w-4" />
Convertir a Pedido
</Button>
)}
{canDelete && (
<Button variant="danger" onClick={() => setShowDeleteModal(true)}>
<Trash2 className="mr-2 h-4 w-4" />
Eliminar
</Button>
)}
</div>
{/* Delete Modal */}
<ConfirmModal
isOpen={showDeleteModal}
onClose={() => setShowDeleteModal(false)}
onConfirm={handleDelete}
title="Eliminar cotizacion"
message={`¿Estas seguro de que deseas eliminar la cotizacion ${quotation.quotationNumber}? Esta accion no se puede deshacer.`}
variant="danger"
confirmText="Eliminar"
isLoading={localProcessing}
/>
{/* Send Modal */}
<ConfirmModal
isOpen={showSendModal}
onClose={() => setShowSendModal(false)}
onConfirm={handleSend}
title="Enviar cotizacion"
message={`¿Enviar la cotizacion ${quotation.quotationNumber} al cliente?`}
variant="info"
confirmText="Enviar"
isLoading={localProcessing}
/>
{/* Accept Modal */}
<ConfirmModal
isOpen={showAcceptModal}
onClose={() => setShowAcceptModal(false)}
onConfirm={handleAccept}
title="Aceptar cotizacion"
message={`¿Marcar la cotizacion ${quotation.quotationNumber} como aceptada por el cliente?`}
variant="success"
confirmText="Aceptar"
isLoading={localProcessing}
/>
{/* Reject Modal */}
<ConfirmModal
isOpen={showRejectModal}
onClose={() => setShowRejectModal(false)}
onConfirm={handleReject}
title="Rechazar cotizacion"
message={`¿Marcar la cotizacion ${quotation.quotationNumber} como rechazada?`}
variant="warning"
confirmText="Rechazar"
isLoading={localProcessing}
/>
{/* Convert Modal */}
<ConfirmModal
isOpen={showConvertModal}
onClose={() => setShowConvertModal(false)}
onConfirm={handleConvert}
title="Convertir a pedido"
message={`¿Convertir la cotizacion ${quotation.quotationNumber} en un pedido de venta?`}
variant="success"
confirmText="Convertir"
isLoading={localProcessing}
/>
</>
);
}

View File

@ -0,0 +1,24 @@
import { Badge } from '@components/atoms/Badge';
export type QuotationStatus = 'draft' | 'sent' | 'accepted' | 'rejected' | 'expired' | 'converted';
interface QuotationStatusBadgeProps {
status: QuotationStatus;
}
const statusConfig: Record<
QuotationStatus,
{ label: string; variant: 'success' | 'danger' | 'warning' | 'default' | 'info' | 'primary' }
> = {
draft: { label: 'Borrador', variant: 'default' },
sent: { label: 'Enviada', variant: 'info' },
accepted: { label: 'Aceptada', variant: 'success' },
rejected: { label: 'Rechazada', variant: 'danger' },
expired: { label: 'Expirada', variant: 'warning' },
converted: { label: 'Convertida', variant: 'primary' },
};
export function QuotationStatusBadge({ status }: QuotationStatusBadgeProps) {
const config = statusConfig[status];
return <Badge variant={config.variant}>{config.label}</Badge>;
}

View File

@ -0,0 +1,418 @@
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { Button } from '@components/atoms/Button';
import { FormField } from '@components/molecules/FormField';
import { Select } from '@components/organisms/Select';
import { DatePicker } from '@components/organisms/DatePicker';
import { SalesOrderLineItems } from './SalesOrderLineItems';
import { SalesOrderSummary } from './SalesOrderSummary';
import type {
SalesOrder,
CreateSalesOrderDto,
UpdateSalesOrderDto,
SalesOrderLine,
CreateSalesOrderLineDto,
InvoicePolicy,
} from '../types';
const salesOrderSchema = z.object({
companyId: z.string().min(1, 'Selecciona una empresa'),
partnerId: z.string().min(1, 'Selecciona un cliente'),
clientOrderRef: z.string().optional(),
orderDate: z.date().optional(),
validityDate: z.date().optional(),
commitmentDate: z.date().optional(),
currencyId: z.string().min(1, 'Selecciona una moneda'),
pricelistId: z.string().optional(),
paymentTermId: z.string().optional(),
salesTeamId: z.string().optional(),
invoicePolicy: z.enum(['order', 'delivery'] as const).optional(),
notes: z.string().optional(),
termsConditions: z.string().optional(),
});
type FormData = z.infer<typeof salesOrderSchema>;
interface SelectOption {
value: string;
label: string;
}
interface ProductOption extends SelectOption {
defaultPrice?: number;
defaultUomId?: string;
}
interface SalesOrderFormProps {
initialData?: SalesOrder;
onSubmit: (data: CreateSalesOrderDto | UpdateSalesOrderDto) => Promise<void>;
onCancel: () => void;
mode: 'create' | 'edit';
isLoading?: boolean;
companies?: SelectOption[];
partners?: SelectOption[];
currencies?: SelectOption[];
pricelists?: SelectOption[];
paymentTerms?: SelectOption[];
salesTeams?: SelectOption[];
products?: ProductOption[];
uoms?: SelectOption[];
taxes?: SelectOption[];
lines?: SalesOrderLine[];
onAddLine?: (data: CreateSalesOrderLineDto) => Promise<void>;
onUpdateLine?: (lineId: string, data: Partial<CreateSalesOrderLineDto>) => Promise<void>;
onRemoveLine?: (lineId: string) => Promise<void>;
linesLoading?: boolean;
}
const invoicePolicyOptions: SelectOption[] = [
{ value: 'order', label: 'Cantidades ordenadas' },
{ value: 'delivery', label: 'Cantidades entregadas' },
];
export function SalesOrderForm({
initialData,
onSubmit,
onCancel,
mode,
isLoading = false,
companies = [],
partners = [],
currencies = [],
pricelists = [],
paymentTerms = [],
salesTeams = [],
products = [],
uoms = [],
taxes = [],
lines = [],
onAddLine,
onUpdateLine,
onRemoveLine,
linesLoading = false,
}: SalesOrderFormProps) {
const isEditing = mode === 'edit';
const {
register,
handleSubmit,
setValue,
watch,
formState: { errors },
} = useForm<FormData>({
resolver: zodResolver(salesOrderSchema),
defaultValues: initialData
? {
companyId: initialData.companyId,
partnerId: initialData.partnerId,
clientOrderRef: initialData.clientOrderRef || '',
orderDate: initialData.orderDate ? new Date(initialData.orderDate) : undefined,
validityDate: initialData.validityDate
? new Date(initialData.validityDate)
: undefined,
commitmentDate: initialData.commitmentDate
? new Date(initialData.commitmentDate)
: undefined,
currencyId: initialData.currencyId,
pricelistId: initialData.pricelistId || '',
paymentTermId: initialData.paymentTermId || '',
salesTeamId: initialData.salesTeamId || '',
invoicePolicy: initialData.invoicePolicy,
notes: initialData.notes || '',
termsConditions: initialData.termsConditions || '',
}
: {
companyId: '',
partnerId: '',
clientOrderRef: '',
orderDate: new Date(),
validityDate: undefined,
commitmentDate: undefined,
currencyId: '',
pricelistId: '',
paymentTermId: '',
salesTeamId: '',
invoicePolicy: 'order' as InvoicePolicy,
notes: '',
termsConditions: '',
},
});
const selectedCompanyId = watch('companyId');
const selectedPartnerId = watch('partnerId');
const selectedCurrencyId = watch('currencyId');
const selectedPricelistId = watch('pricelistId');
const selectedPaymentTermId = watch('paymentTermId');
const selectedSalesTeamId = watch('salesTeamId');
const selectedInvoicePolicy = watch('invoicePolicy');
const orderDate = watch('orderDate');
const validityDate = watch('validityDate');
const commitmentDate = watch('commitmentDate');
const totals = lines.reduce(
(acc, line) => ({
subtotal: acc.subtotal + line.amountUntaxed,
taxAmount: acc.taxAmount + line.amountTax,
total: acc.total + line.amountTotal,
}),
{ subtotal: 0, taxAmount: 0, total: 0 }
);
const handleFormSubmit = async (data: FormData) => {
const formattedData: CreateSalesOrderDto | UpdateSalesOrderDto = {
companyId: data.companyId,
partnerId: data.partnerId,
currencyId: data.currencyId,
...(data.clientOrderRef && { clientOrderRef: data.clientOrderRef }),
...(data.orderDate && { orderDate: data.orderDate.toISOString().split('T')[0] }),
...(data.validityDate && { validityDate: data.validityDate.toISOString().split('T')[0] }),
...(data.commitmentDate && {
commitmentDate: data.commitmentDate.toISOString().split('T')[0],
}),
...(data.pricelistId && { pricelistId: data.pricelistId }),
...(data.paymentTermId && { paymentTermId: data.paymentTermId }),
...(data.salesTeamId && { salesTeamId: data.salesTeamId }),
...(data.invoicePolicy && { invoicePolicy: data.invoicePolicy }),
...(data.notes && { notes: data.notes }),
...(data.termsConditions && { termsConditions: data.termsConditions }),
};
await onSubmit(formattedData);
};
const handleAddLine = async (data: CreateSalesOrderLineDto) => {
if (onAddLine) {
await onAddLine(data);
}
};
const handleUpdateLine = async (lineId: string, data: Partial<CreateSalesOrderLineDto>) => {
if (onUpdateLine) {
await onUpdateLine(lineId, data);
}
};
const handleRemoveLine = async (lineId: string) => {
if (onRemoveLine) {
await onRemoveLine(lineId);
}
};
return (
<form onSubmit={handleSubmit(handleFormSubmit)} className="space-y-8">
{/* Header Section */}
<div className="space-y-4">
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100">
Informacion general
</h3>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{!isEditing && (
<FormField
label="Empresa"
error={errors.companyId?.message}
required
>
<Select
options={companies}
value={selectedCompanyId}
onChange={(value) => setValue('companyId', value as string)}
placeholder="Seleccionar empresa..."
searchable
/>
</FormField>
)}
<FormField
label="Cliente"
error={errors.partnerId?.message}
required
>
<Select
options={partners}
value={selectedPartnerId}
onChange={(value) => setValue('partnerId', value as string)}
placeholder="Buscar cliente..."
searchable
/>
</FormField>
<FormField
label="Referencia del cliente"
error={errors.clientOrderRef?.message}
>
<input
{...register('clientOrderRef')}
type="text"
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100"
placeholder="PO-12345"
/>
</FormField>
</div>
</div>
{/* Dates Section */}
<div className="space-y-4">
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100">Fechas</h3>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
<FormField label="Fecha del pedido">
<DatePicker
value={orderDate}
onChange={(date) => setValue('orderDate', date || undefined)}
placeholder="Seleccionar fecha..."
/>
</FormField>
<FormField label="Fecha de validez">
<DatePicker
value={validityDate}
onChange={(date) => setValue('validityDate', date || undefined)}
placeholder="Seleccionar fecha..."
minDate={orderDate}
/>
</FormField>
<FormField label="Fecha de compromiso">
<DatePicker
value={commitmentDate}
onChange={(date) => setValue('commitmentDate', date || undefined)}
placeholder="Seleccionar fecha..."
minDate={orderDate}
/>
</FormField>
</div>
</div>
{/* Pricing Section */}
<div className="space-y-4">
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100">
Facturacion y precios
</h3>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
<FormField
label="Moneda"
error={errors.currencyId?.message}
required
>
<Select
options={currencies}
value={selectedCurrencyId}
onChange={(value) => setValue('currencyId', value as string)}
placeholder="Seleccionar moneda..."
/>
</FormField>
<FormField label="Lista de precios">
<Select
options={pricelists}
value={selectedPricelistId || ''}
onChange={(value) => setValue('pricelistId', value as string)}
placeholder="Seleccionar lista..."
clearable
/>
</FormField>
<FormField label="Terminos de pago">
<Select
options={paymentTerms}
value={selectedPaymentTermId || ''}
onChange={(value) => setValue('paymentTermId', value as string)}
placeholder="Seleccionar terminos..."
clearable
/>
</FormField>
<FormField label="Equipo de ventas">
<Select
options={salesTeams}
value={selectedSalesTeamId || ''}
onChange={(value) => setValue('salesTeamId', value as string)}
placeholder="Seleccionar equipo..."
clearable
/>
</FormField>
<FormField label="Politica de facturacion">
<Select
options={invoicePolicyOptions}
value={selectedInvoicePolicy || 'order'}
onChange={(value) => setValue('invoicePolicy', value as InvoicePolicy)}
placeholder="Seleccionar politica..."
/>
</FormField>
</div>
</div>
{/* Line Items Section - Only show in edit mode or when there's an order */}
{isEditing && (
<div className="space-y-4">
<SalesOrderLineItems
items={lines}
onAddItem={handleAddLine}
onUpdateItem={handleUpdateLine}
onRemoveItem={handleRemoveLine}
currencyCode={
currencies.find((c) => c.value === selectedCurrencyId)?.label?.substring(0, 3) ||
'MXN'
}
isLoading={linesLoading}
products={products}
uoms={uoms}
taxes={taxes}
/>
<div className="flex justify-end">
<SalesOrderSummary
subtotal={totals.subtotal}
taxAmount={totals.taxAmount}
total={totals.total}
currencyCode={
currencies.find((c) => c.value === selectedCurrencyId)?.label?.substring(0, 3) ||
'MXN'
}
className="w-full sm:w-80"
/>
</div>
</div>
)}
{/* Notes Section */}
<div className="space-y-4">
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100">Notas</h3>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
<FormField label="Notas internas">
<textarea
{...register('notes')}
rows={3}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100"
placeholder="Notas internas del pedido..."
/>
</FormField>
<FormField label="Terminos y condiciones">
<textarea
{...register('termsConditions')}
rows={3}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100"
placeholder="Terminos y condiciones para el cliente..."
/>
</FormField>
</div>
</div>
{/* Actions */}
<div className="flex justify-end gap-3 border-t border-gray-200 pt-6 dark:border-gray-700">
<Button type="button" variant="outline" onClick={onCancel}>
Cancelar
</Button>
<Button type="submit" isLoading={isLoading}>
{isEditing ? 'Guardar cambios' : 'Crear pedido'}
</Button>
</div>
</form>
);
}

View File

@ -0,0 +1,265 @@
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { Button } from '@components/atoms/Button';
import { FormField } from '@components/molecules/FormField';
import { Select } from '@components/organisms/Select';
import { Modal, ModalContent, ModalFooter } from '@components/organisms/Modal';
import type { SalesOrderLine, CreateSalesOrderLineDto } from '../types';
const lineItemSchema = z.object({
productId: z.string().min(1, 'Selecciona un producto'),
description: z.string().min(1, 'Descripcion requerida'),
quantity: z.number().min(0.01, 'Cantidad debe ser mayor a 0'),
uomId: z.string().min(1, 'Selecciona una unidad de medida'),
priceUnit: z.number().min(0, 'Precio no puede ser negativo'),
discount: z.number().min(0).max(100).optional(),
taxIds: z.array(z.string()).optional(),
});
type FormData = z.infer<typeof lineItemSchema>;
interface ProductOption {
value: string;
label: string;
defaultPrice?: number;
defaultUomId?: string;
}
interface UomOption {
value: string;
label: string;
}
interface TaxOption {
value: string;
label: string;
}
interface SalesOrderLineItemFormProps {
isOpen: boolean;
onClose: () => void;
item?: SalesOrderLine;
onSave: (data: CreateSalesOrderLineDto) => Promise<void>;
isLoading?: boolean;
products?: ProductOption[];
uoms?: UomOption[];
taxes?: TaxOption[];
}
export function SalesOrderLineItemForm({
isOpen,
onClose,
item,
onSave,
isLoading = false,
products = [],
uoms = [],
taxes = [],
}: SalesOrderLineItemFormProps) {
const isEditing = !!item;
const {
register,
handleSubmit,
setValue,
watch,
reset,
formState: { errors },
} = useForm<FormData>({
resolver: zodResolver(lineItemSchema),
defaultValues: item
? {
productId: item.productId,
description: item.description,
quantity: item.quantity,
uomId: item.uomId,
priceUnit: item.priceUnit,
discount: item.discount,
taxIds: item.taxIds,
}
: {
productId: '',
description: '',
quantity: 1,
uomId: '',
priceUnit: 0,
discount: 0,
taxIds: [],
},
});
const selectedProductId = watch('productId');
const selectedUomId = watch('uomId');
const selectedTaxIds = watch('taxIds') || [];
const quantity = watch('quantity');
const priceUnit = watch('priceUnit');
const discount = watch('discount') || 0;
const subtotal = quantity * priceUnit * (1 - discount / 100);
const handleProductChange = (productId: string) => {
setValue('productId', productId);
const product = products.find((p) => p.value === productId);
if (product) {
if (!item) {
setValue('description', product.label);
}
if (product.defaultPrice !== undefined) {
setValue('priceUnit', product.defaultPrice);
}
if (product.defaultUomId) {
setValue('uomId', product.defaultUomId);
}
}
};
const handleFormSubmit = async (data: FormData) => {
await onSave({
productId: data.productId,
description: data.description,
quantity: data.quantity,
uomId: data.uomId,
priceUnit: data.priceUnit,
discount: data.discount,
taxIds: data.taxIds,
});
reset();
onClose();
};
const handleClose = () => {
reset();
onClose();
};
return (
<Modal
isOpen={isOpen}
onClose={handleClose}
title={isEditing ? 'Editar linea' : 'Agregar linea'}
size="lg"
>
<form onSubmit={handleSubmit(handleFormSubmit)}>
<ModalContent className="space-y-4">
<FormField
label="Producto"
error={errors.productId?.message}
required
>
<Select
options={products}
value={selectedProductId}
onChange={(value) => handleProductChange(value as string)}
placeholder="Buscar producto..."
searchable
/>
</FormField>
<FormField
label="Descripcion"
error={errors.description?.message}
required
>
<textarea
{...register('description')}
rows={2}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100"
placeholder="Descripcion del producto"
/>
</FormField>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<FormField
label="Cantidad"
error={errors.quantity?.message}
required
>
<input
{...register('quantity', { valueAsNumber: true })}
type="number"
step="0.01"
min="0.01"
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100"
/>
</FormField>
<FormField
label="Unidad"
error={errors.uomId?.message}
required
>
<Select
options={uoms}
value={selectedUomId}
onChange={(value) => setValue('uomId', value as string)}
placeholder="Seleccionar..."
/>
</FormField>
<FormField
label="Precio unitario"
error={errors.priceUnit?.message}
required
>
<input
{...register('priceUnit', { valueAsNumber: true })}
type="number"
step="0.01"
min="0"
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100"
/>
</FormField>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<FormField
label="Descuento (%)"
error={errors.discount?.message}
>
<input
{...register('discount', { valueAsNumber: true })}
type="number"
step="0.01"
min="0"
max="100"
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100"
/>
</FormField>
<FormField label="Impuestos">
<Select
options={taxes}
value={selectedTaxIds}
onChange={(value) => setValue('taxIds', value as string[])}
placeholder="Seleccionar impuestos..."
multiple
/>
</FormField>
</div>
<div className="rounded-lg bg-gray-50 p-3 dark:bg-gray-800">
<div className="flex justify-between text-sm font-medium">
<span className="text-gray-600 dark:text-gray-400">Subtotal</span>
<span className="text-gray-900 dark:text-gray-100">
{new Intl.NumberFormat('es-MX', {
style: 'currency',
currency: 'MXN',
}).format(subtotal)}
</span>
</div>
</div>
</ModalContent>
<ModalFooter>
<Button type="button" variant="outline" onClick={handleClose}>
Cancelar
</Button>
<Button type="submit" isLoading={isLoading}>
{isEditing ? 'Guardar cambios' : 'Agregar'}
</Button>
</ModalFooter>
</form>
</Modal>
);
}

View File

@ -0,0 +1,284 @@
import { useState } from 'react';
import { Plus, Edit2, Trash2 } from 'lucide-react';
import { Button } from '@components/atoms/Button';
import { cn } from '@utils/cn';
import { SalesOrderLineItemForm } from './SalesOrderLineItemForm';
import type { SalesOrderLine, CreateSalesOrderLineDto } from '../types';
interface ProductOption {
value: string;
label: string;
defaultPrice?: number;
defaultUomId?: string;
}
interface UomOption {
value: string;
label: string;
}
interface TaxOption {
value: string;
label: string;
}
interface SalesOrderLineItemsProps {
items: SalesOrderLine[];
onAddItem: (data: CreateSalesOrderLineDto) => Promise<void>;
onUpdateItem: (lineId: string, data: Partial<CreateSalesOrderLineDto>) => Promise<void>;
onRemoveItem: (lineId: string) => Promise<void>;
currencyCode?: string;
isLoading?: boolean;
readOnly?: boolean;
products?: ProductOption[];
uoms?: UomOption[];
taxes?: TaxOption[];
className?: string;
}
function formatCurrency(amount: number, currencyCode: string = 'MXN'): string {
return new Intl.NumberFormat('es-MX', {
style: 'currency',
currency: currencyCode,
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(amount);
}
export function SalesOrderLineItems({
items,
onAddItem,
onUpdateItem,
onRemoveItem,
currencyCode = 'MXN',
isLoading = false,
readOnly = false,
products = [],
uoms = [],
taxes = [],
className,
}: SalesOrderLineItemsProps) {
const [isFormOpen, setIsFormOpen] = useState(false);
const [editingItem, setEditingItem] = useState<SalesOrderLine | undefined>(undefined);
const [deletingId, setDeletingId] = useState<string | null>(null);
const handleAddClick = () => {
setEditingItem(undefined);
setIsFormOpen(true);
};
const handleEditClick = (item: SalesOrderLine) => {
setEditingItem(item);
setIsFormOpen(true);
};
const handleRemoveClick = async (lineId: string) => {
setDeletingId(lineId);
try {
await onRemoveItem(lineId);
} finally {
setDeletingId(null);
}
};
const handleSave = async (data: CreateSalesOrderLineDto) => {
if (editingItem) {
await onUpdateItem(editingItem.id, data);
} else {
await onAddItem(data);
}
};
const totals = items.reduce(
(acc, item) => ({
untaxed: acc.untaxed + item.amountUntaxed,
tax: acc.tax + item.amountTax,
total: acc.total + item.amountTotal,
}),
{ untaxed: 0, tax: 0, total: 0 }
);
return (
<div className={cn('space-y-4', className)}>
<div className="flex items-center justify-between">
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100">
Lineas del pedido
</h3>
{!readOnly && (
<Button
type="button"
variant="outline"
size="sm"
onClick={handleAddClick}
leftIcon={<Plus className="h-4 w-4" />}
>
Agregar linea
</Button>
)}
</div>
<div className="overflow-x-auto rounded-lg border border-gray-200 dark:border-gray-700">
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead className="bg-gray-50 dark:bg-gray-800">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">
Producto
</th>
<th className="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">
Descripcion
</th>
<th className="px-4 py-3 text-right text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">
Cantidad
</th>
<th className="px-4 py-3 text-right text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">
Precio unit.
</th>
<th className="px-4 py-3 text-right text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">
Descuento
</th>
<th className="px-4 py-3 text-right text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">
Impuesto
</th>
<th className="px-4 py-3 text-right text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">
Subtotal
</th>
{!readOnly && (
<th className="px-4 py-3 text-right text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">
Acciones
</th>
)}
</tr>
</thead>
<tbody className="divide-y divide-gray-200 bg-white dark:divide-gray-700 dark:bg-gray-900">
{items.length === 0 ? (
<tr>
<td
colSpan={readOnly ? 7 : 8}
className="px-4 py-8 text-center text-sm text-gray-500 dark:text-gray-400"
>
No hay lineas en el pedido.{' '}
{!readOnly && (
<button
type="button"
onClick={handleAddClick}
className="text-primary-600 hover:text-primary-700 dark:text-primary-400"
>
Agregar una linea
</button>
)}
</td>
</tr>
) : (
items.map((item) => (
<tr
key={item.id}
className="hover:bg-gray-50 dark:hover:bg-gray-800"
>
<td className="whitespace-nowrap px-4 py-3 text-sm font-medium text-gray-900 dark:text-gray-100">
{item.productName || item.productId}
</td>
<td className="max-w-xs truncate px-4 py-3 text-sm text-gray-600 dark:text-gray-400">
{item.description}
</td>
<td className="whitespace-nowrap px-4 py-3 text-right text-sm text-gray-600 dark:text-gray-400">
{item.quantity} {item.uomName}
</td>
<td className="whitespace-nowrap px-4 py-3 text-right text-sm text-gray-600 dark:text-gray-400">
{formatCurrency(item.priceUnit, currencyCode)}
</td>
<td className="whitespace-nowrap px-4 py-3 text-right text-sm text-gray-600 dark:text-gray-400">
{item.discount > 0 ? `${item.discount}%` : '-'}
</td>
<td className="whitespace-nowrap px-4 py-3 text-right text-sm text-gray-600 dark:text-gray-400">
{formatCurrency(item.amountTax, currencyCode)}
</td>
<td className="whitespace-nowrap px-4 py-3 text-right text-sm font-medium text-gray-900 dark:text-gray-100">
{formatCurrency(item.amountTotal, currencyCode)}
</td>
{!readOnly && (
<td className="whitespace-nowrap px-4 py-3 text-right">
<div className="flex items-center justify-end gap-1">
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => handleEditClick(item)}
disabled={isLoading}
aria-label="Editar"
>
<Edit2 className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => handleRemoveClick(item.id)}
disabled={isLoading}
isLoading={deletingId === item.id}
aria-label="Eliminar"
>
<Trash2 className="h-4 w-4 text-danger-600" />
</Button>
</div>
</td>
)}
</tr>
))
)}
</tbody>
{items.length > 0 && (
<tfoot className="bg-gray-50 dark:bg-gray-800">
<tr>
<td
colSpan={readOnly ? 5 : 6}
className="px-4 py-3 text-right text-sm font-medium text-gray-600 dark:text-gray-400"
>
Subtotal
</td>
<td className="px-4 py-3 text-right text-sm font-medium text-gray-900 dark:text-gray-100">
{formatCurrency(totals.untaxed, currencyCode)}
</td>
{!readOnly && <td />}
</tr>
<tr>
<td
colSpan={readOnly ? 5 : 6}
className="px-4 py-3 text-right text-sm font-medium text-gray-600 dark:text-gray-400"
>
Impuestos
</td>
<td className="px-4 py-3 text-right text-sm font-medium text-gray-900 dark:text-gray-100">
{formatCurrency(totals.tax, currencyCode)}
</td>
{!readOnly && <td />}
</tr>
<tr>
<td
colSpan={readOnly ? 5 : 6}
className="px-4 py-3 text-right text-sm font-semibold text-gray-900 dark:text-gray-100"
>
Total
</td>
<td className="px-4 py-3 text-right text-lg font-bold text-gray-900 dark:text-gray-100">
{formatCurrency(totals.total, currencyCode)}
</td>
{!readOnly && <td />}
</tr>
</tfoot>
)}
</table>
</div>
<SalesOrderLineItemForm
isOpen={isFormOpen}
onClose={() => setIsFormOpen(false)}
item={editingItem}
onSave={handleSave}
isLoading={isLoading}
products={products}
uoms={uoms}
taxes={taxes}
/>
</div>
);
}

View File

@ -0,0 +1,22 @@
import { Badge } from '@components/atoms/Badge';
import type { SalesOrderStatus } from '../types';
interface SalesOrderStatusBadgeProps {
status: SalesOrderStatus;
}
const statusConfig: Record<
SalesOrderStatus,
{ label: string; variant: 'success' | 'danger' | 'warning' | 'default' | 'info' | 'primary' }
> = {
draft: { label: 'Borrador', variant: 'default' },
sent: { label: 'Enviado', variant: 'info' },
sale: { label: 'Venta', variant: 'primary' },
done: { label: 'Completado', variant: 'success' },
cancelled: { label: 'Cancelado', variant: 'danger' },
};
export function SalesOrderStatusBadge({ status }: SalesOrderStatusBadgeProps) {
const config = statusConfig[status];
return <Badge variant={config.variant}>{config.label}</Badge>;
}

View File

@ -0,0 +1,76 @@
import { cn } from '@utils/cn';
interface SalesOrderSummaryProps {
subtotal: number;
taxAmount: number;
discountAmount?: number;
shippingAmount?: number;
total: number;
currencyCode?: string;
className?: string;
}
function formatCurrency(amount: number, currencyCode: string = 'MXN'): string {
return new Intl.NumberFormat('es-MX', {
style: 'currency',
currency: currencyCode,
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(amount);
}
export function SalesOrderSummary({
subtotal,
taxAmount,
discountAmount = 0,
shippingAmount = 0,
total,
currencyCode = 'MXN',
className,
}: SalesOrderSummaryProps) {
return (
<div
className={cn(
'rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-800',
className
)}
>
<h4 className="mb-3 text-sm font-semibold text-gray-900 dark:text-gray-100">
Resumen del pedido
</h4>
<div className="space-y-2 text-sm">
<div className="flex justify-between text-gray-600 dark:text-gray-400">
<span>Subtotal</span>
<span>{formatCurrency(subtotal, currencyCode)}</span>
</div>
{discountAmount > 0 && (
<div className="flex justify-between text-danger-600 dark:text-danger-400">
<span>Descuento</span>
<span>-{formatCurrency(discountAmount, currencyCode)}</span>
</div>
)}
<div className="flex justify-between text-gray-600 dark:text-gray-400">
<span>Impuestos</span>
<span>{formatCurrency(taxAmount, currencyCode)}</span>
</div>
{shippingAmount > 0 && (
<div className="flex justify-between text-gray-600 dark:text-gray-400">
<span>Envio</span>
<span>{formatCurrency(shippingAmount, currencyCode)}</span>
</div>
)}
<div className="border-t border-gray-200 pt-2 dark:border-gray-700">
<div className="flex justify-between font-semibold text-gray-900 dark:text-gray-100">
<span>Total</span>
<span className="text-lg">{formatCurrency(total, currencyCode)}</span>
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,10 @@
// Sales Order Components
export { SalesOrderForm } from './SalesOrderForm';
export { SalesOrderLineItems } from './SalesOrderLineItems';
export { SalesOrderLineItemForm } from './SalesOrderLineItemForm';
export { SalesOrderSummary } from './SalesOrderSummary';
export { SalesOrderStatusBadge } from './SalesOrderStatusBadge';
// Quotation Components
export { QuotationStatusBadge, type QuotationStatus } from './QuotationStatusBadge';
export { QuotationActions } from './QuotationActions';

View File

@ -1,3 +1,4 @@
export * from './api/sales.api';
export * from './types';
export * from './hooks';
export * from './components';

View File

@ -185,6 +185,32 @@ export interface CreateQuotationDto {
termsConditions?: string;
}
export interface UpdateQuotationDto {
partnerId?: string;
quotationDate?: string;
validityDate?: string | null;
currencyId?: string;
notes?: string | null;
termsConditions?: string | null;
}
export interface CreateQuotationLineDto {
productId: string;
description: string;
quantity: number;
uomId: string;
priceUnit: number;
discount?: number;
}
export interface UpdateQuotationLineDto {
description?: string;
quantity?: number;
uomId?: string;
priceUnit?: number;
discount?: number;
}
export interface QuotationFilters {
companyId?: string;
partnerId?: string;

View File

@ -0,0 +1,638 @@
import { useState, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { useForm, useFieldArray, Controller } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import {
ArrowLeft,
Plus,
Trash2,
FileText,
Package,
Calculator,
} from 'lucide-react';
import { Button } from '@components/atoms/Button';
import { Input } from '@components/atoms/Input';
import { Label } from '@components/atoms/Label';
import { Card, CardHeader, CardTitle, CardContent, CardFooter } from '@components/molecules/Card';
import { Alert } from '@components/molecules/Alert';
import { Select, type SelectOption } from '@components/organisms/Select';
import { Breadcrumbs } from '@components/organisms/Breadcrumbs';
import { useToast } from '@components/organisms/Toast';
import { useQuotations } from '@features/sales/hooks';
import { useCustomers } from '@features/partners/hooks';
import { useCompanyStore } from '@stores/useCompanyStore';
import { formatNumber } from '@utils/formatters';
// ==================== Types ====================
interface LineItem {
id: string;
productId: string;
productName: string;
description: string;
quantity: number;
uomId: string;
uomName: string;
priceUnit: number;
discount: number;
taxRate: number;
}
// ==================== Validation Schema ====================
const lineItemSchema = z.object({
id: z.string(),
productId: z.string().min(1, 'Producto requerido'),
productName: z.string(),
description: z.string(),
quantity: z.number().min(0.01, 'Cantidad debe ser mayor a 0'),
uomId: z.string().min(1, 'Unidad requerida'),
uomName: z.string(),
priceUnit: z.number().min(0, 'Precio no puede ser negativo'),
discount: z.number().min(0).max(100, 'Descuento debe estar entre 0 y 100'),
taxRate: z.number().min(0).max(100),
});
const quotationSchema = z.object({
partnerId: z.string().min(1, 'Cliente es requerido'),
quotationDate: z.string().min(1, 'Fecha de cotizacion requerida'),
validityDate: z.string().optional(),
currencyId: z.string().min(1, 'Moneda requerida'),
notes: z.string().optional(),
termsConditions: z.string().optional(),
lines: z.array(lineItemSchema).min(1, 'Debe agregar al menos un producto'),
});
type QuotationFormData = z.infer<typeof quotationSchema>;
// ==================== Helpers ====================
const generateTempId = () => `temp_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
const formatCurrency = (value: number): string => {
return formatNumber(value, 'es-MX', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
};
// Mock products for demo (should come from products API)
const mockProducts: SelectOption[] = [
{ value: 'prod-001', label: 'Producto A - SKU001' },
{ value: 'prod-002', label: 'Producto B - SKU002' },
{ value: 'prod-003', label: 'Producto C - SKU003' },
{ value: 'prod-004', label: 'Servicio Premium' },
{ value: 'prod-005', label: 'Licencia Software Anual' },
];
// Mock UOMs (should come from UOM API)
const mockUoms: SelectOption[] = [
{ value: 'uom-001', label: 'Unidad' },
{ value: 'uom-002', label: 'Kg' },
{ value: 'uom-003', label: 'Litro' },
{ value: 'uom-004', label: 'Hora' },
{ value: 'uom-005', label: 'Caja' },
];
// Mock currencies (should come from currency API)
const mockCurrencies: SelectOption[] = [
{ value: 'curr-001', label: 'MXN - Peso Mexicano' },
{ value: 'curr-002', label: 'USD - Dolar Estadounidense' },
{ value: 'curr-003', label: 'EUR - Euro' },
];
// Default tax rate (16% IVA for Mexico)
const DEFAULT_TAX_RATE = 16;
// ==================== Component ====================
export function QuotationCreatePage() {
const navigate = useNavigate();
const { showToast } = useToast();
const { currentCompany } = useCompanyStore();
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
// API hooks
const { createQuotation } = useQuotations({ autoFetch: false });
const { partners: customers, isLoading: loadingCustomers } = useCustomers({ limit: 100 });
// Calculate default validity date (30 days from now)
const defaultValidityDate = new Date();
defaultValidityDate.setDate(defaultValidityDate.getDate() + 30);
// Form setup
const {
register,
control,
handleSubmit,
watch,
setValue,
formState: { errors },
} = useForm<QuotationFormData>({
resolver: zodResolver(quotationSchema),
defaultValues: {
partnerId: '',
quotationDate: new Date().toISOString().split('T')[0],
validityDate: defaultValidityDate.toISOString().split('T')[0],
currencyId: 'curr-001',
notes: '',
termsConditions: '',
lines: [],
},
});
const { fields, append, remove } = useFieldArray({
control,
name: 'lines',
});
// Watch for calculations
const watchLines = watch('lines');
// Calculate totals
const calculateLineTotals = useCallback((line: LineItem) => {
const subtotal = line.quantity * line.priceUnit;
const discountAmt = subtotal * (line.discount / 100);
const afterDiscount = subtotal - discountAmt;
const taxAmt = afterDiscount * (line.taxRate / 100);
const total = afterDiscount + taxAmt;
return { subtotal, discountAmt, afterDiscount, taxAmt, total };
}, []);
const totals = watchLines.reduce(
(acc, line) => {
const lineTotals = calculateLineTotals(line);
return {
subtotal: acc.subtotal + lineTotals.subtotal,
lineDiscount: acc.lineDiscount + lineTotals.discountAmt,
taxAmount: acc.taxAmount + lineTotals.taxAmt,
lineTotal: acc.lineTotal + lineTotals.total,
};
},
{ subtotal: 0, lineDiscount: 0, taxAmount: 0, lineTotal: 0 }
);
// Build customer options
const customerOptions: SelectOption[] = customers.map((c) => ({
value: c.id,
label: c.name,
}));
// Add new line item
const handleAddLine = () => {
append({
id: generateTempId(),
productId: '',
productName: '',
description: '',
quantity: 1,
uomId: 'uom-001',
uomName: 'Unidad',
priceUnit: 0,
discount: 0,
taxRate: DEFAULT_TAX_RATE,
});
};
// Handle product selection for a line
const handleProductChange = (index: number, productId: string) => {
const product = mockProducts.find((p) => p.value === productId);
if (product) {
setValue(`lines.${index}.productId`, productId);
setValue(`lines.${index}.productName`, product.label);
setValue(`lines.${index}.description`, product.label);
// In real implementation, fetch product price from API
setValue(`lines.${index}.priceUnit`, Math.random() * 1000 + 100);
}
};
// Handle UOM selection for a line
const handleUomChange = (index: number, uomId: string) => {
const uom = mockUoms.find((u) => u.value === uomId);
if (uom) {
setValue(`lines.${index}.uomId`, uomId);
setValue(`lines.${index}.uomName`, uom.label);
}
};
// Submit handler
const onSubmit = async (data: QuotationFormData) => {
if (!currentCompany) {
setError('No hay una empresa seleccionada');
return;
}
setIsSubmitting(true);
setError(null);
try {
const quotationData = {
companyId: currentCompany.id,
partnerId: data.partnerId,
quotationDate: data.quotationDate,
validityDate: data.validityDate || undefined,
currencyId: data.currencyId,
notes: data.notes || undefined,
termsConditions: data.termsConditions || undefined,
};
const newQuotation = await createQuotation(quotationData);
showToast({
type: 'success',
title: 'Cotizacion creada',
message: `La cotizacion ${newQuotation.quotationNumber} ha sido creada exitosamente.`,
});
navigate(`/sales/quotations/${newQuotation.id}`);
} catch (err) {
const message = err instanceof Error ? err.message : 'Error al crear la cotizacion';
setError(message);
showToast({
type: 'error',
title: 'Error',
message,
});
} finally {
setIsSubmitting(false);
}
};
return (
<div className="space-y-6 p-6">
<Breadcrumbs
items={[
{ label: 'Ventas', href: '/sales' },
{ label: 'Cotizaciones', href: '/sales/quotations' },
{ label: 'Nueva Cotizacion' },
]}
/>
<div className="flex items-center gap-4">
<Button variant="ghost" size="sm" onClick={() => navigate('/sales/quotations')}>
<ArrowLeft className="mr-2 h-4 w-4" />
Volver
</Button>
<div>
<h1 className="text-2xl font-bold text-gray-900">Crear Cotizacion</h1>
<p className="text-sm text-gray-500">
Complete los datos para crear una nueva cotizacion de venta
</p>
</div>
</div>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
{error && (
<Alert variant="danger" title="Error" onClose={() => setError(null)}>
{error}
</Alert>
)}
{/* Section 1: Customer Information */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<FileText className="h-5 w-5" />
Informacion del Cliente
</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
<div>
<Label required>Cliente</Label>
<Controller
name="partnerId"
control={control}
render={({ field }) => (
<Select
options={customerOptions}
value={field.value}
onChange={(val) => field.onChange(val as string)}
placeholder="Seleccionar cliente..."
searchable
error={!!errors.partnerId}
disabled={loadingCustomers}
/>
)}
/>
{errors.partnerId && (
<p className="mt-1 text-sm text-danger-500">{errors.partnerId.message}</p>
)}
</div>
</div>
</CardContent>
</Card>
{/* Section 2: Quotation Details */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Calculator className="h-5 w-5" />
Detalles de la Cotizacion
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
<div>
<Label required>Fecha de Cotizacion</Label>
<Input
type="date"
{...register('quotationDate')}
error={!!errors.quotationDate}
/>
{errors.quotationDate && (
<p className="mt-1 text-sm text-danger-500">{errors.quotationDate.message}</p>
)}
</div>
<div>
<Label>Vigencia Hasta</Label>
<Input
type="date"
{...register('validityDate')}
/>
</div>
<div>
<Label required>Moneda</Label>
<Controller
name="currencyId"
control={control}
render={({ field }) => (
<Select
options={mockCurrencies}
value={field.value}
onChange={(val) => field.onChange(val as string)}
placeholder="Seleccionar moneda..."
error={!!errors.currencyId}
/>
)}
/>
{errors.currencyId && (
<p className="mt-1 text-sm text-danger-500">{errors.currencyId.message}</p>
)}
</div>
<div className="md:col-span-2 lg:col-span-3">
<Label>Notas</Label>
<textarea
{...register('notes')}
rows={3}
className="block w-full rounded-md border border-gray-300 px-3 py-2 text-sm shadow-sm placeholder:text-gray-400 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
placeholder="Notas adicionales para la cotizacion..."
/>
</div>
<div className="md:col-span-2 lg:col-span-3">
<Label>Terminos y Condiciones</Label>
<textarea
{...register('termsConditions')}
rows={3}
className="block w-full rounded-md border border-gray-300 px-3 py-2 text-sm shadow-sm placeholder:text-gray-400 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
placeholder="Terminos y condiciones para el cliente..."
/>
</div>
</div>
</CardContent>
</Card>
{/* Section 3: 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={handleAddLine}>
<Plus className="mr-2 h-4 w-4" />
Agregar Producto
</Button>
</div>
</CardHeader>
<CardContent>
{fields.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-center">
<Package className="mb-4 h-12 w-12 text-gray-300" />
<h3 className="text-lg font-medium text-gray-900">Sin productos</h3>
<p className="mt-1 text-sm text-gray-500">
Agregue productos a la cotizacion usando el boton superior
</p>
<Button
type="button"
variant="primary"
className="mt-4"
onClick={handleAddLine}
>
<Plus className="mr-2 h-4 w-4" />
Agregar primer producto
</Button>
</div>
) : (
<div className="space-y-4">
{/* Desktop Table Header */}
<div className="hidden lg:grid lg:grid-cols-12 lg:gap-4 lg:border-b lg:pb-2 lg:text-sm lg:font-medium lg:text-gray-700">
<div className="col-span-3">Producto</div>
<div className="col-span-2">Descripcion</div>
<div className="col-span-1 text-right">Cant.</div>
<div className="col-span-1">Unidad</div>
<div className="col-span-1 text-right">Precio</div>
<div className="col-span-1 text-right">Dto. %</div>
<div className="col-span-1 text-right">IVA %</div>
<div className="col-span-1 text-right">Total</div>
<div className="col-span-1"></div>
</div>
{/* Line Items */}
{fields.map((field, index) => {
const line = watchLines[index];
const lineTotals = line ? calculateLineTotals(line) : { total: 0 };
return (
<div
key={field.id}
className="grid grid-cols-1 gap-4 rounded-lg border bg-gray-50 p-4 lg:grid-cols-12 lg:items-center lg:bg-transparent lg:p-0 lg:border-0"
>
{/* Product */}
<div className="lg:col-span-3">
<Label className="lg:hidden">Producto</Label>
<Controller
name={`lines.${index}.productId`}
control={control}
render={({ field: selectField }) => (
<Select
options={mockProducts}
value={selectField.value}
onChange={(val) => handleProductChange(index, val as string)}
placeholder="Buscar producto..."
searchable
error={!!errors.lines?.[index]?.productId}
/>
)}
/>
{errors.lines?.[index]?.productId && (
<p className="mt-1 text-xs text-danger-500">
{errors.lines[index]?.productId?.message}
</p>
)}
</div>
{/* Description */}
<div className="lg:col-span-2">
<Label className="lg:hidden">Descripcion</Label>
<Input
{...register(`lines.${index}.description`)}
placeholder="Descripcion"
inputSize="sm"
/>
</div>
{/* Quantity */}
<div className="lg:col-span-1">
<Label className="lg:hidden">Cantidad</Label>
<Input
type="number"
step="0.01"
min="0.01"
{...register(`lines.${index}.quantity`, { valueAsNumber: true })}
className="text-right"
inputSize="sm"
error={!!errors.lines?.[index]?.quantity}
/>
</div>
{/* UOM */}
<div className="lg:col-span-1">
<Label className="lg:hidden">Unidad</Label>
<Controller
name={`lines.${index}.uomId`}
control={control}
render={({ field: selectField }) => (
<Select
options={mockUoms}
value={selectField.value}
onChange={(val) => handleUomChange(index, val as string)}
placeholder="UOM"
/>
)}
/>
</div>
{/* Unit Price */}
<div className="lg:col-span-1">
<Label className="lg:hidden">Precio Unitario</Label>
<Input
type="number"
step="0.01"
min="0"
{...register(`lines.${index}.priceUnit`, { valueAsNumber: true })}
className="text-right"
inputSize="sm"
error={!!errors.lines?.[index]?.priceUnit}
/>
</div>
{/* Discount */}
<div className="lg:col-span-1">
<Label className="lg:hidden">Descuento %</Label>
<Input
type="number"
step="0.01"
min="0"
max="100"
{...register(`lines.${index}.discount`, { valueAsNumber: true })}
className="text-right"
inputSize="sm"
/>
</div>
{/* Tax Rate */}
<div className="lg:col-span-1">
<Label className="lg:hidden">IVA %</Label>
<Input
type="number"
step="0.01"
min="0"
max="100"
{...register(`lines.${index}.taxRate`, { valueAsNumber: true })}
className="text-right"
inputSize="sm"
/>
</div>
{/* Line Total */}
<div className="lg:col-span-1 lg:text-right">
<Label className="lg:hidden">Total Linea</Label>
<div className="text-sm font-medium text-gray-900">
${formatCurrency(lineTotals.total)}
</div>
</div>
{/* Remove Button */}
<div className="lg:col-span-1 lg:text-right">
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => remove(index)}
className="text-danger-600 hover:bg-danger-50 hover:text-danger-700"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
);
})}
{errors.lines?.root && (
<p className="text-sm text-danger-500">{errors.lines.root.message}</p>
)}
{errors.lines?.message && (
<p className="text-sm text-danger-500">{errors.lines.message}</p>
)}
</div>
)}
</CardContent>
</Card>
{/* Section 4: Summary */}
<Card>
<CardHeader>
<CardTitle>Resumen de la Cotizacion</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-col items-end space-y-3">
<div className="grid w-full max-w-md grid-cols-2 gap-4 text-sm">
<div className="text-gray-600">Subtotal:</div>
<div className="text-right font-medium">${formatCurrency(totals.subtotal)}</div>
<div className="text-gray-600">Descuento en lineas:</div>
<div className="text-right font-medium text-danger-600">
-${formatCurrency(totals.lineDiscount)}
</div>
<div className="text-gray-600">IVA:</div>
<div className="text-right font-medium">${formatCurrency(totals.taxAmount)}</div>
<div className="border-t-2 pt-3 text-lg font-bold text-gray-900">TOTAL:</div>
<div className="border-t-2 pt-3 text-right text-lg font-bold text-primary-600">
${formatCurrency(totals.lineTotal)}
</div>
</div>
</div>
</CardContent>
<CardFooter className="flex justify-end gap-3">
<Button
type="button"
variant="outline"
onClick={() => navigate('/sales/quotations')}
disabled={isSubmitting}
>
Cancelar
</Button>
<Button type="submit" isLoading={isSubmitting}>
Crear Cotizacion
</Button>
</CardFooter>
</Card>
</form>
</div>
);
}
export default QuotationCreatePage;

View File

@ -0,0 +1,354 @@
import { useNavigate, useParams } from 'react-router-dom';
import {
ArrowLeft,
FileText,
User,
Calendar,
Building2,
Package,
DollarSign,
Clock,
} 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 { Breadcrumbs } from '@components/organisms/Breadcrumbs';
import { DataTable, type Column } from '@components/organisms/DataTable';
import { ErrorEmptyState } from '@components/templates/EmptyState';
import { useQuotation } from '@features/sales/hooks';
import { QuotationStatusBadge } from '@features/sales/components/QuotationStatusBadge';
import { QuotationActions } from '@features/sales/components/QuotationActions';
import type { QuotationLine } from '@features/sales/types';
import { formatDate, formatNumber } from '@utils/formatters';
const formatCurrency = (value: number, currencyCode?: string): string => {
const formatted = formatNumber(value, 'es-MX', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
return currencyCode ? `${formatted} ${currencyCode}` : `$${formatted}`;
};
export function QuotationDetailPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { quotation, isLoading, error, refresh } = useQuotation(id || null);
const lineColumns: Column<QuotationLine>[] = [
{
key: 'product',
header: 'Producto',
render: (line) => (
<div>
<div className="font-medium text-gray-900">{line.productName || line.productId}</div>
<div className="text-sm text-gray-500">{line.description}</div>
</div>
),
},
{
key: 'quantity',
header: 'Cantidad',
render: (line) => (
<div className="text-right">
<div className="font-medium">{line.quantity} {line.uomName}</div>
</div>
),
},
{
key: 'priceUnit',
header: 'Precio Unit.',
render: (line) => (
<div className="text-right font-medium">
{formatCurrency(line.priceUnit)}
</div>
),
},
{
key: 'discount',
header: 'Descuento',
render: (line) => (
<div className="text-right text-gray-600">
{line.discount > 0 ? `${line.discount}%` : '-'}
</div>
),
},
{
key: 'tax',
header: 'Impuesto',
render: (line) => (
<div className="text-right text-gray-600">
{formatCurrency(line.amountTax)}
</div>
),
},
{
key: 'total',
header: 'Total',
render: (line) => (
<div className="text-right font-semibold text-gray-900">
{formatCurrency(line.amountTotal)}
</div>
),
},
];
if (isLoading) {
return (
<div className="flex h-96 items-center justify-center">
<Spinner size="lg" />
</div>
);
}
if (error || !quotation) {
return (
<div className="p-6">
<ErrorEmptyState
title="Cotizacion no encontrada"
description="No se pudo cargar la informacion de la cotizacion."
onRetry={refresh}
/>
</div>
);
}
// Check validity
const isExpired = quotation.validityDate && new Date(quotation.validityDate) < new Date() && quotation.status === 'sent';
return (
<div className="space-y-6 p-6">
<Breadcrumbs
items={[
{ label: 'Ventas', href: '/sales' },
{ label: 'Cotizaciones', href: '/sales/quotations' },
{ label: quotation.quotationNumber },
]}
/>
{/* Header */}
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-4">
<Button variant="ghost" size="sm" onClick={() => navigate('/sales/quotations')}>
<ArrowLeft className="mr-2 h-4 w-4" />
Volver
</Button>
<div className="flex items-center gap-3">
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-purple-100">
<FileText className="h-6 w-6 text-purple-600" />
</div>
<div>
<h1 className="text-2xl font-bold text-gray-900">{quotation.quotationNumber}</h1>
<p className="text-sm text-gray-500">Cotizacion de venta</p>
</div>
<QuotationStatusBadge status={quotation.status as 'draft' | 'sent' | 'accepted' | 'rejected' | 'expired' | 'converted'} />
{isExpired && (
<span className="inline-flex rounded-full bg-amber-100 px-2 py-1 text-xs font-medium text-amber-700">
Vencida
</span>
)}
</div>
</div>
<QuotationActions quotation={quotation} onRefresh={refresh} />
</div>
{/* Status Card */}
<Card>
<CardContent className="p-4">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-green-100">
<DollarSign className="h-5 w-5 text-green-600" />
</div>
<div>
<div className="text-sm text-gray-500">Total</div>
<div className="text-lg font-bold text-green-600">
{formatCurrency(quotation.amountTotal, quotation.currencyCode)}
</div>
</div>
</div>
</CardContent>
</Card>
{/* Main Content - Two Columns */}
<div className="grid gap-6 lg:grid-cols-3">
{/* Left Column - Quotation Info */}
<div className="space-y-6 lg:col-span-1">
{/* Customer Info */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<User className="h-5 w-5" />
Cliente
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-3">
<div>
<div className="font-medium text-gray-900">{quotation.partnerName || quotation.partnerId}</div>
</div>
</div>
</CardContent>
</Card>
{/* Dates */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Calendar className="h-5 w-5" />
Fechas
</CardTitle>
</CardHeader>
<CardContent>
<dl className="space-y-3">
<div className="flex justify-between">
<dt className="text-sm text-gray-500">Fecha cotizacion</dt>
<dd className="text-sm font-medium">{formatDate(quotation.quotationDate, 'short')}</dd>
</div>
{quotation.validityDate && (
<div className="flex justify-between">
<dt className="text-sm text-gray-500">Vigencia hasta</dt>
<dd className={`text-sm font-medium ${isExpired ? 'text-red-600' : ''}`}>
{formatDate(quotation.validityDate, 'short')}
</dd>
</div>
)}
<div className="flex justify-between">
<dt className="text-sm text-gray-500">Creada</dt>
<dd className="text-sm font-medium">{formatDate(quotation.createdAt, 'short')}</dd>
</div>
</dl>
</CardContent>
</Card>
{/* Company Info */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Building2 className="h-5 w-5" />
Empresa
</CardTitle>
</CardHeader>
<CardContent>
<dl className="space-y-3">
<div>
<dt className="text-sm text-gray-500">Empresa</dt>
<dd className="text-sm font-medium">{quotation.companyName || quotation.companyId}</dd>
</div>
<div className="flex justify-between">
<dt className="text-sm text-gray-500">Moneda</dt>
<dd className="text-sm font-medium">{quotation.currencyCode || 'MXN'}</dd>
</div>
</dl>
</CardContent>
</Card>
</div>
{/* Right Column - Lines & Totals */}
<div className="space-y-6 lg:col-span-2">
{/* Quotation Lines */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Package className="h-5 w-5" />
Lineas de la cotizacion
</CardTitle>
</CardHeader>
<CardContent>
{quotation.lines && quotation.lines.length > 0 ? (
<DataTable
data={quotation.lines}
columns={lineColumns}
isLoading={false}
/>
) : (
<div className="py-8 text-center text-gray-500">
No hay lineas en esta cotizacion
</div>
)}
</CardContent>
</Card>
{/* Totals */}
<Card>
<CardHeader>
<CardTitle>Resumen</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-3">
<div className="flex justify-between text-sm">
<span className="text-gray-500">Subtotal</span>
<span className="font-medium">{formatCurrency(quotation.amountUntaxed, quotation.currencyCode)}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-500">Impuestos</span>
<span className="font-medium">{formatCurrency(quotation.amountTax, quotation.currencyCode)}</span>
</div>
<div className="border-t pt-3">
<div className="flex justify-between">
<span className="text-lg font-semibold text-gray-900">Total</span>
<span className="text-lg font-bold text-green-600">
{formatCurrency(quotation.amountTotal, quotation.currencyCode)}
</span>
</div>
</div>
</div>
</CardContent>
</Card>
{/* Notes */}
{(quotation.notes || quotation.termsConditions) && (
<Card>
<CardHeader>
<CardTitle>Notas y condiciones</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
{quotation.notes && (
<div>
<dt className="text-sm font-medium text-gray-500">Notas</dt>
<dd className="mt-1 whitespace-pre-wrap text-sm text-gray-900">{quotation.notes}</dd>
</div>
)}
{quotation.termsConditions && (
<div>
<dt className="text-sm font-medium text-gray-500">Terminos y condiciones</dt>
<dd className="mt-1 whitespace-pre-wrap text-sm text-gray-900">{quotation.termsConditions}</dd>
</div>
)}
</div>
</CardContent>
</Card>
)}
{/* System Info */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Clock className="h-5 w-5" />
Informacion del sistema
</CardTitle>
</CardHeader>
<CardContent>
<dl className="grid gap-4 sm:grid-cols-2">
<div>
<dt className="text-sm text-gray-500">ID</dt>
<dd className="font-mono text-sm text-gray-900">{quotation.id}</dd>
</div>
<div>
<dt className="text-sm text-gray-500">Tenant ID</dt>
<dd className="font-mono text-sm text-gray-900">{quotation.tenantId}</dd>
</div>
<div>
<dt className="text-sm text-gray-500">Creado</dt>
<dd className="text-sm text-gray-900">{formatDate(quotation.createdAt, 'full')}</dd>
</div>
</dl>
</CardContent>
</Card>
</div>
</div>
</div>
);
}
export default QuotationDetailPage;

View File

@ -0,0 +1,780 @@
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;

View File

@ -283,7 +283,7 @@ export function QuotationsPage() {
<RefreshCw className={`mr-2 h-4 w-4 ${isLoading ? 'animate-spin' : ''}`} />
Actualizar
</Button>
<Button>
<Button onClick={() => navigate('/sales/quotations/new')}>
<Plus className="mr-2 h-4 w-4" />
Nueva cotizacion
</Button>

View File

@ -0,0 +1,782 @@
import { useState, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { useForm, useFieldArray, Controller } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import {
ArrowLeft,
Plus,
Trash2,
ShoppingCart,
Package,
Calculator,
} from 'lucide-react';
import { Button } from '@components/atoms/Button';
import { Input } from '@components/atoms/Input';
import { Label } from '@components/atoms/Label';
import { Card, CardHeader, CardTitle, CardContent, CardFooter } from '@components/molecules/Card';
import { Alert } from '@components/molecules/Alert';
import { Select, type SelectOption } from '@components/organisms/Select';
import { Breadcrumbs } from '@components/organisms/Breadcrumbs';
import { useToast } from '@components/organisms/Toast';
import { useSalesOrders } from '@features/sales/hooks';
import { useCustomers } from '@features/partners/hooks';
import { useWarehouses } from '@features/inventory/hooks';
import { useCompanyStore } from '@stores/useCompanyStore';
import { formatNumber } from '@utils/formatters';
// ==================== Types ====================
interface LineItem {
id: string;
productId: string;
productName: string;
description: string;
quantity: number;
uomId: string;
uomName: string;
priceUnit: number;
discount: number;
taxRate: number;
taxIds: string[];
}
// ==================== Validation Schema ====================
const lineItemSchema = z.object({
id: z.string(),
productId: z.string().min(1, 'Producto requerido'),
productName: z.string(),
description: z.string(),
quantity: z.number().min(0.01, 'Cantidad debe ser mayor a 0'),
uomId: z.string().min(1, 'Unidad requerida'),
uomName: z.string(),
priceUnit: z.number().min(0, 'Precio no puede ser negativo'),
discount: z.number().min(0).max(100, 'Descuento debe estar entre 0 y 100'),
taxRate: z.number().min(0).max(100),
taxIds: z.array(z.string()),
});
const salesOrderSchema = z.object({
partnerId: z.string().min(1, 'Cliente es requerido'),
billingStreet: z.string().optional(),
billingCity: z.string().optional(),
billingState: z.string().optional(),
billingZip: z.string().optional(),
billingCountry: z.string().optional(),
sameAsShipping: z.boolean(),
shippingStreet: z.string().optional(),
shippingCity: z.string().optional(),
shippingState: z.string().optional(),
shippingZip: z.string().optional(),
shippingCountry: z.string().optional(),
orderDate: z.string().min(1, 'Fecha de pedido requerida'),
commitmentDate: z.string().optional(),
warehouseId: z.string().optional(),
currencyId: z.string().min(1, 'Moneda requerida'),
notes: z.string().optional(),
discountAmount: z.number().min(0).optional(),
shippingAmount: z.number().min(0).optional(),
lines: z.array(lineItemSchema).min(1, 'Debe agregar al menos un producto'),
});
type SalesOrderFormData = z.infer<typeof salesOrderSchema>;
// ==================== Helpers ====================
const generateTempId = () => `temp_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
const formatCurrency = (value: number): string => {
return formatNumber(value, 'es-MX', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
};
// Mock products for demo (should come from products API)
const mockProducts: SelectOption[] = [
{ value: 'prod-001', label: 'Producto A - SKU001' },
{ value: 'prod-002', label: 'Producto B - SKU002' },
{ value: 'prod-003', label: 'Producto C - SKU003' },
{ value: 'prod-004', label: 'Servicio Premium' },
{ value: 'prod-005', label: 'Licencia Software Anual' },
];
// Mock UOMs (should come from UOM API)
const mockUoms: SelectOption[] = [
{ value: 'uom-001', label: 'Unidad' },
{ value: 'uom-002', label: 'Kg' },
{ value: 'uom-003', label: 'Litro' },
{ value: 'uom-004', label: 'Hora' },
{ value: 'uom-005', label: 'Caja' },
];
// Mock currencies (should come from currency API)
const mockCurrencies: SelectOption[] = [
{ value: 'curr-001', label: 'MXN - Peso Mexicano' },
{ value: 'curr-002', label: 'USD - Dolar Estadounidense' },
{ value: 'curr-003', label: 'EUR - Euro' },
];
// Default tax rate (16% IVA for Mexico)
const DEFAULT_TAX_RATE = 16;
// ==================== Component ====================
export function SalesOrderCreatePage() {
const navigate = useNavigate();
const { showToast } = useToast();
const { currentCompany } = useCompanyStore();
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
// API hooks
const { createOrder } = useSalesOrders({ autoFetch: false });
const { partners: customers, isLoading: loadingCustomers } = useCustomers({ limit: 100 });
const { warehouses, isLoading: loadingWarehouses } = useWarehouses();
// Form setup
const {
register,
control,
handleSubmit,
watch,
setValue,
formState: { errors },
} = useForm<SalesOrderFormData>({
resolver: zodResolver(salesOrderSchema),
defaultValues: {
partnerId: '',
billingStreet: '',
billingCity: '',
billingState: '',
billingZip: '',
billingCountry: 'Mexico',
sameAsShipping: true,
shippingStreet: '',
shippingCity: '',
shippingState: '',
shippingZip: '',
shippingCountry: 'Mexico',
orderDate: new Date().toISOString().split('T')[0],
commitmentDate: '',
warehouseId: '',
currencyId: 'curr-001',
notes: '',
discountAmount: 0,
shippingAmount: 0,
lines: [],
},
});
const { fields, append, remove } = useFieldArray({
control,
name: 'lines',
});
// Watch for calculations
const watchLines = watch('lines');
const watchSameAsShipping = watch('sameAsShipping');
const watchDiscountAmount = watch('discountAmount') || 0;
const watchShippingAmount = watch('shippingAmount') || 0;
// Calculate totals
const calculateLineTotals = useCallback((line: LineItem) => {
const subtotal = line.quantity * line.priceUnit;
const discountAmt = subtotal * (line.discount / 100);
const afterDiscount = subtotal - discountAmt;
const taxAmt = afterDiscount * (line.taxRate / 100);
const total = afterDiscount + taxAmt;
return { subtotal, discountAmt, afterDiscount, taxAmt, total };
}, []);
const totals = watchLines.reduce(
(acc, line) => {
const lineTotals = calculateLineTotals(line);
return {
subtotal: acc.subtotal + lineTotals.subtotal,
lineDiscount: acc.lineDiscount + lineTotals.discountAmt,
taxAmount: acc.taxAmount + lineTotals.taxAmt,
lineTotal: acc.lineTotal + lineTotals.total,
};
},
{ subtotal: 0, lineDiscount: 0, taxAmount: 0, lineTotal: 0 }
);
const grandTotal = totals.lineTotal - watchDiscountAmount + watchShippingAmount;
// Build customer options
const customerOptions: SelectOption[] = customers.map((c) => ({
value: c.id,
label: c.name,
}));
// Build warehouse options
const warehouseOptions: SelectOption[] = warehouses.map((w) => ({
value: w.id,
label: w.name,
}));
// Add new line item
const handleAddLine = () => {
append({
id: generateTempId(),
productId: '',
productName: '',
description: '',
quantity: 1,
uomId: 'uom-001',
uomName: 'Unidad',
priceUnit: 0,
discount: 0,
taxRate: DEFAULT_TAX_RATE,
taxIds: [],
});
};
// Handle product selection for a line
const handleProductChange = (index: number, productId: string) => {
const product = mockProducts.find((p) => p.value === productId);
if (product) {
setValue(`lines.${index}.productId`, productId);
setValue(`lines.${index}.productName`, product.label);
setValue(`lines.${index}.description`, product.label);
// In real implementation, fetch product price from API
setValue(`lines.${index}.priceUnit`, Math.random() * 1000 + 100);
}
};
// Handle UOM selection for a line
const handleUomChange = (index: number, uomId: string) => {
const uom = mockUoms.find((u) => u.value === uomId);
if (uom) {
setValue(`lines.${index}.uomId`, uomId);
setValue(`lines.${index}.uomName`, uom.label);
}
};
// Submit handler
const onSubmit = async (data: SalesOrderFormData) => {
if (!currentCompany) {
setError('No hay una empresa seleccionada');
return;
}
setIsSubmitting(true);
setError(null);
try {
const orderData = {
companyId: currentCompany.id,
partnerId: data.partnerId,
orderDate: data.orderDate,
commitmentDate: data.commitmentDate || undefined,
currencyId: data.currencyId,
notes: data.notes || undefined,
};
const newOrder = await createOrder(orderData);
showToast({
type: 'success',
title: 'Pedido creado',
message: `El pedido ${newOrder.name} ha sido creado exitosamente.`,
});
navigate(`/sales/orders/${newOrder.id}`);
} catch (err) {
const message = err instanceof Error ? err.message : 'Error al crear el pedido';
setError(message);
showToast({
type: 'error',
title: 'Error',
message,
});
} finally {
setIsSubmitting(false);
}
};
return (
<div className="space-y-6 p-6">
<Breadcrumbs
items={[
{ label: 'Ventas', href: '/sales' },
{ label: 'Pedidos', href: '/sales/orders' },
{ label: 'Nuevo Pedido' },
]}
/>
<div className="flex items-center gap-4">
<Button variant="ghost" size="sm" onClick={() => navigate('/sales/orders')}>
<ArrowLeft className="mr-2 h-4 w-4" />
Volver
</Button>
<div>
<h1 className="text-2xl font-bold text-gray-900">Crear Pedido de Venta</h1>
<p className="text-sm text-gray-500">
Complete los datos para crear un nuevo pedido de venta
</p>
</div>
</div>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
{error && (
<Alert variant="danger" title="Error" onClose={() => setError(null)}>
{error}
</Alert>
)}
{/* Section 1: Customer Information */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<ShoppingCart className="h-5 w-5" />
Informacion del Cliente
</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
<div>
<Label required>Cliente</Label>
<Controller
name="partnerId"
control={control}
render={({ field }) => (
<Select
options={customerOptions}
value={field.value}
onChange={(val) => field.onChange(val as string)}
placeholder="Seleccionar cliente..."
searchable
error={!!errors.partnerId}
disabled={loadingCustomers}
/>
)}
/>
{errors.partnerId && (
<p className="mt-1 text-sm text-danger-500">{errors.partnerId.message}</p>
)}
</div>
</div>
{/* Billing Address */}
<div>
<h4 className="mb-3 font-medium text-gray-700">Direccion de Facturacion</h4>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
<div className="lg:col-span-2">
<Label>Calle y numero</Label>
<Input {...register('billingStreet')} placeholder="Av. Principal #123" />
</div>
<div>
<Label>Ciudad</Label>
<Input {...register('billingCity')} placeholder="Ciudad" />
</div>
<div>
<Label>Estado/Provincia</Label>
<Input {...register('billingState')} placeholder="Estado" />
</div>
<div>
<Label>Codigo Postal</Label>
<Input {...register('billingZip')} placeholder="12345" />
</div>
<div>
<Label>Pais</Label>
<Input {...register('billingCountry')} placeholder="Mexico" />
</div>
</div>
</div>
{/* Shipping Address */}
<div>
<div className="mb-3 flex items-center gap-3">
<h4 className="font-medium text-gray-700">Direccion de Envio</h4>
<label className="flex items-center gap-2 text-sm text-gray-600">
<input
type="checkbox"
{...register('sameAsShipping')}
className="h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500"
/>
Igual que facturacion
</label>
</div>
{!watchSameAsShipping && (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
<div className="lg:col-span-2">
<Label>Calle y numero</Label>
<Input {...register('shippingStreet')} placeholder="Av. Principal #123" />
</div>
<div>
<Label>Ciudad</Label>
<Input {...register('shippingCity')} placeholder="Ciudad" />
</div>
<div>
<Label>Estado/Provincia</Label>
<Input {...register('shippingState')} placeholder="Estado" />
</div>
<div>
<Label>Codigo Postal</Label>
<Input {...register('shippingZip')} placeholder="12345" />
</div>
<div>
<Label>Pais</Label>
<Input {...register('shippingCountry')} placeholder="Mexico" />
</div>
</div>
)}
</div>
</CardContent>
</Card>
{/* Section 2: Order Details */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Calculator className="h-5 w-5" />
Detalles del Pedido
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
<div>
<Label required>Fecha del Pedido</Label>
<Input
type="date"
{...register('orderDate')}
error={!!errors.orderDate}
/>
{errors.orderDate && (
<p className="mt-1 text-sm text-danger-500">{errors.orderDate.message}</p>
)}
</div>
<div>
<Label>Fecha de Entrega Solicitada</Label>
<Input
type="date"
{...register('commitmentDate')}
/>
</div>
<div>
<Label>Almacen</Label>
<Controller
name="warehouseId"
control={control}
render={({ field }) => (
<Select
options={warehouseOptions}
value={field.value}
onChange={(val) => field.onChange(val as string)}
placeholder="Seleccionar almacen..."
clearable
disabled={loadingWarehouses}
/>
)}
/>
</div>
<div>
<Label required>Moneda</Label>
<Controller
name="currencyId"
control={control}
render={({ field }) => (
<Select
options={mockCurrencies}
value={field.value}
onChange={(val) => field.onChange(val as string)}
placeholder="Seleccionar moneda..."
error={!!errors.currencyId}
/>
)}
/>
{errors.currencyId && (
<p className="mt-1 text-sm text-danger-500">{errors.currencyId.message}</p>
)}
</div>
<div className="md:col-span-2 lg:col-span-3">
<Label>Notas</Label>
<textarea
{...register('notes')}
rows={3}
className="block w-full rounded-md border border-gray-300 px-3 py-2 text-sm shadow-sm placeholder:text-gray-400 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
placeholder="Notas adicionales para el pedido..."
/>
</div>
</div>
</CardContent>
</Card>
{/* Section 3: 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 del Pedido
</CardTitle>
<Button type="button" variant="outline" size="sm" onClick={handleAddLine}>
<Plus className="mr-2 h-4 w-4" />
Agregar Producto
</Button>
</div>
</CardHeader>
<CardContent>
{fields.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-center">
<Package className="mb-4 h-12 w-12 text-gray-300" />
<h3 className="text-lg font-medium text-gray-900">Sin productos</h3>
<p className="mt-1 text-sm text-gray-500">
Agregue productos al pedido usando el boton superior
</p>
<Button
type="button"
variant="primary"
className="mt-4"
onClick={handleAddLine}
>
<Plus className="mr-2 h-4 w-4" />
Agregar primer producto
</Button>
</div>
) : (
<div className="space-y-4">
{/* Desktop Table Header */}
<div className="hidden lg:grid lg:grid-cols-12 lg:gap-4 lg:border-b lg:pb-2 lg:text-sm lg:font-medium lg:text-gray-700">
<div className="col-span-3">Producto</div>
<div className="col-span-2">Descripcion</div>
<div className="col-span-1 text-right">Cant.</div>
<div className="col-span-1">Unidad</div>
<div className="col-span-1 text-right">Precio</div>
<div className="col-span-1 text-right">Dto. %</div>
<div className="col-span-1 text-right">IVA %</div>
<div className="col-span-1 text-right">Total</div>
<div className="col-span-1"></div>
</div>
{/* Line Items */}
{fields.map((field, index) => {
const line = watchLines[index];
const lineTotals = line ? calculateLineTotals(line) : { total: 0 };
return (
<div
key={field.id}
className="grid grid-cols-1 gap-4 rounded-lg border bg-gray-50 p-4 lg:grid-cols-12 lg:items-center lg:bg-transparent lg:p-0 lg:border-0"
>
{/* Product */}
<div className="lg:col-span-3">
<Label className="lg:hidden">Producto</Label>
<Controller
name={`lines.${index}.productId`}
control={control}
render={({ field: selectField }) => (
<Select
options={mockProducts}
value={selectField.value}
onChange={(val) => handleProductChange(index, val as string)}
placeholder="Buscar producto..."
searchable
error={!!errors.lines?.[index]?.productId}
/>
)}
/>
{errors.lines?.[index]?.productId && (
<p className="mt-1 text-xs text-danger-500">
{errors.lines[index]?.productId?.message}
</p>
)}
</div>
{/* Description */}
<div className="lg:col-span-2">
<Label className="lg:hidden">Descripcion</Label>
<Input
{...register(`lines.${index}.description`)}
placeholder="Descripcion"
inputSize="sm"
/>
</div>
{/* Quantity */}
<div className="lg:col-span-1">
<Label className="lg:hidden">Cantidad</Label>
<Input
type="number"
step="0.01"
min="0.01"
{...register(`lines.${index}.quantity`, { valueAsNumber: true })}
className="text-right"
inputSize="sm"
error={!!errors.lines?.[index]?.quantity}
/>
</div>
{/* UOM */}
<div className="lg:col-span-1">
<Label className="lg:hidden">Unidad</Label>
<Controller
name={`lines.${index}.uomId`}
control={control}
render={({ field: selectField }) => (
<Select
options={mockUoms}
value={selectField.value}
onChange={(val) => handleUomChange(index, val as string)}
placeholder="UOM"
/>
)}
/>
</div>
{/* Unit Price */}
<div className="lg:col-span-1">
<Label className="lg:hidden">Precio Unitario</Label>
<Input
type="number"
step="0.01"
min="0"
{...register(`lines.${index}.priceUnit`, { valueAsNumber: true })}
className="text-right"
inputSize="sm"
error={!!errors.lines?.[index]?.priceUnit}
/>
</div>
{/* Discount */}
<div className="lg:col-span-1">
<Label className="lg:hidden">Descuento %</Label>
<Input
type="number"
step="0.01"
min="0"
max="100"
{...register(`lines.${index}.discount`, { valueAsNumber: true })}
className="text-right"
inputSize="sm"
/>
</div>
{/* Tax Rate */}
<div className="lg:col-span-1">
<Label className="lg:hidden">IVA %</Label>
<Input
type="number"
step="0.01"
min="0"
max="100"
{...register(`lines.${index}.taxRate`, { valueAsNumber: true })}
className="text-right"
inputSize="sm"
/>
</div>
{/* Line Total */}
<div className="lg:col-span-1 lg:text-right">
<Label className="lg:hidden">Total Linea</Label>
<div className="text-sm font-medium text-gray-900">
${formatCurrency(lineTotals.total)}
</div>
</div>
{/* Remove Button */}
<div className="lg:col-span-1 lg:text-right">
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => remove(index)}
className="text-danger-600 hover:bg-danger-50 hover:text-danger-700"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
);
})}
{errors.lines?.root && (
<p className="text-sm text-danger-500">{errors.lines.root.message}</p>
)}
{errors.lines?.message && (
<p className="text-sm text-danger-500">{errors.lines.message}</p>
)}
</div>
)}
</CardContent>
</Card>
{/* Section 4: Summary */}
<Card>
<CardHeader>
<CardTitle>Resumen del Pedido</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-col items-end space-y-3">
<div className="grid w-full max-w-md grid-cols-2 gap-4 text-sm">
<div className="text-gray-600">Subtotal:</div>
<div className="text-right font-medium">${formatCurrency(totals.subtotal)}</div>
<div className="text-gray-600">Descuento en lineas:</div>
<div className="text-right font-medium text-danger-600">
-${formatCurrency(totals.lineDiscount)}
</div>
<div className="text-gray-600">IVA:</div>
<div className="text-right font-medium">${formatCurrency(totals.taxAmount)}</div>
<div className="border-t pt-2 text-gray-600">Subtotal con IVA:</div>
<div className="border-t pt-2 text-right font-medium">
${formatCurrency(totals.lineTotal)}
</div>
<div className="flex items-center gap-2 text-gray-600">
<span>Descuento adicional:</span>
</div>
<div className="text-right">
<Input
type="number"
step="0.01"
min="0"
{...register('discountAmount', { valueAsNumber: true })}
className="w-32 text-right"
inputSize="sm"
/>
</div>
<div className="flex items-center gap-2 text-gray-600">
<span>Envio:</span>
</div>
<div className="text-right">
<Input
type="number"
step="0.01"
min="0"
{...register('shippingAmount', { valueAsNumber: true })}
className="w-32 text-right"
inputSize="sm"
/>
</div>
<div className="border-t-2 pt-3 text-lg font-bold text-gray-900">TOTAL:</div>
<div className="border-t-2 pt-3 text-right text-lg font-bold text-primary-600">
${formatCurrency(grandTotal)}
</div>
</div>
</div>
</CardContent>
<CardFooter className="flex justify-end gap-3">
<Button
type="button"
variant="outline"
onClick={() => navigate('/sales/orders')}
disabled={isSubmitting}
>
Cancelar
</Button>
<Button type="submit" isLoading={isSubmitting}>
Crear Pedido
</Button>
</CardFooter>
</Card>
</form>
</div>
);
}
export default SalesOrderCreatePage;

View File

@ -0,0 +1,726 @@
import { useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import {
ArrowLeft,
Edit,
Trash2,
CheckCircle,
XCircle,
FileText,
ShoppingCart,
User,
Calendar,
Building2,
Package,
Truck,
DollarSign,
Clock,
RefreshCw,
} 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 { Breadcrumbs } from '@components/organisms/Breadcrumbs';
import { ConfirmModal } from '@components/organisms/Modal';
import { DataTable, type Column } from '@components/organisms/DataTable';
import { useToast } from '@components/organisms/Toast';
import { ErrorEmptyState } from '@components/templates/EmptyState';
import { useSalesOrder, salesApi } from '@features/sales';
import type {
SalesOrderLine,
SalesOrderStatus,
InvoiceStatus,
DeliveryStatus,
} from '@features/sales/types';
import { formatDate, formatNumber } from '@utils/formatters';
const statusLabels: Record<SalesOrderStatus, string> = {
draft: 'Borrador',
sent: 'Enviado',
sale: 'Confirmado',
done: 'Completado',
cancelled: 'Cancelado',
};
const statusColors: Record<SalesOrderStatus, string> = {
draft: 'bg-gray-100 text-gray-700',
sent: 'bg-blue-100 text-blue-700',
sale: 'bg-green-100 text-green-700',
done: 'bg-purple-100 text-purple-700',
cancelled: 'bg-red-100 text-red-700',
};
const invoiceStatusLabels: Record<InvoiceStatus, string> = {
pending: 'Pendiente',
partial: 'Parcial',
invoiced: 'Facturado',
};
const invoiceStatusColors: Record<InvoiceStatus, string> = {
pending: 'bg-amber-100 text-amber-700',
partial: 'bg-blue-100 text-blue-700',
invoiced: 'bg-green-100 text-green-700',
};
const deliveryStatusLabels: Record<DeliveryStatus, string> = {
pending: 'Pendiente',
partial: 'Parcial',
delivered: 'Entregado',
};
const deliveryStatusColors: Record<DeliveryStatus, string> = {
pending: 'bg-amber-100 text-amber-700',
partial: 'bg-blue-100 text-blue-700',
delivered: 'bg-green-100 text-green-700',
};
const formatCurrency = (value: number, currencyCode?: string): string => {
const formatted = formatNumber(value, 'es-MX', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
return currencyCode ? `${formatted} ${currencyCode}` : `$${formatted}`;
};
interface StatusBadgeProps {
status: SalesOrderStatus;
}
function StatusBadge({ status }: StatusBadgeProps) {
return (
<span
className={`inline-flex rounded-full px-3 py-1 text-sm font-medium ${statusColors[status]}`}
>
{statusLabels[status]}
</span>
);
}
interface InvoiceStatusBadgeProps {
status: InvoiceStatus;
}
function InvoiceStatusBadge({ status }: InvoiceStatusBadgeProps) {
return (
<span
className={`inline-flex rounded-full px-2 py-1 text-xs font-medium ${invoiceStatusColors[status]}`}
>
{invoiceStatusLabels[status]}
</span>
);
}
interface DeliveryStatusBadgeProps {
status: DeliveryStatus;
}
function DeliveryStatusBadge({ status }: DeliveryStatusBadgeProps) {
return (
<span
className={`inline-flex rounded-full px-2 py-1 text-xs font-medium ${deliveryStatusColors[status]}`}
>
{deliveryStatusLabels[status]}
</span>
);
}
export function SalesOrderDetailPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { showToast } = useToast();
const { order, isLoading, error, refresh } = useSalesOrder(id || null);
const [showDeleteModal, setShowDeleteModal] = useState(false);
const [showConfirmModal, setShowConfirmModal] = useState(false);
const [showCancelModal, setShowCancelModal] = useState(false);
const [showInvoiceModal, setShowInvoiceModal] = useState(false);
const [isProcessing, setIsProcessing] = useState(false);
const handleDelete = async () => {
if (!id) return;
setIsProcessing(true);
try {
await salesApi.deleteOrder(id);
showToast({
type: 'success',
title: 'Pedido eliminado',
message: 'El pedido ha sido eliminado exitosamente.',
});
navigate('/sales/orders');
} catch {
showToast({
type: 'error',
title: 'Error',
message: 'No se pudo eliminar el pedido.',
});
} finally {
setIsProcessing(false);
setShowDeleteModal(false);
}
};
const handleConfirm = async () => {
if (!id) return;
setIsProcessing(true);
try {
await salesApi.confirmOrder(id);
showToast({
type: 'success',
title: 'Pedido confirmado',
message: 'El pedido ha sido confirmado exitosamente.',
});
refresh();
} catch {
showToast({
type: 'error',
title: 'Error',
message: 'No se pudo confirmar el pedido.',
});
} finally {
setIsProcessing(false);
setShowConfirmModal(false);
}
};
const handleCancel = async () => {
if (!id) return;
setIsProcessing(true);
try {
await salesApi.cancelOrder(id);
showToast({
type: 'success',
title: 'Pedido cancelado',
message: 'El pedido ha sido cancelado.',
});
refresh();
} catch {
showToast({
type: 'error',
title: 'Error',
message: 'No se pudo cancelar el pedido.',
});
} finally {
setIsProcessing(false);
setShowCancelModal(false);
}
};
const handleCreateInvoice = async () => {
if (!id) return;
setIsProcessing(true);
try {
const result = await salesApi.createInvoice(id);
showToast({
type: 'success',
title: 'Factura creada',
message: 'La factura ha sido creada exitosamente.',
});
refresh();
if (result.invoiceId) {
navigate(`/accounting/invoices/${result.invoiceId}`);
}
} catch {
showToast({
type: 'error',
title: 'Error',
message: 'No se pudo crear la factura.',
});
} finally {
setIsProcessing(false);
setShowInvoiceModal(false);
}
};
const lineColumns: Column<SalesOrderLine>[] = [
{
key: 'product',
header: 'Producto',
render: (line) => (
<div>
<div className="font-medium text-gray-900">{line.productName || line.productId}</div>
<div className="text-sm text-gray-500">{line.description}</div>
</div>
),
},
{
key: 'quantity',
header: 'Cantidad',
render: (line) => (
<div className="text-right">
<div className="font-medium">{line.quantity} {line.uomName}</div>
{line.qtyDelivered > 0 && (
<div className="text-xs text-gray-500">
Entregado: {line.qtyDelivered}
</div>
)}
{line.qtyInvoiced > 0 && (
<div className="text-xs text-gray-500">
Facturado: {line.qtyInvoiced}
</div>
)}
</div>
),
},
{
key: 'priceUnit',
header: 'Precio Unit.',
render: (line) => (
<div className="text-right font-medium">
{formatCurrency(line.priceUnit)}
</div>
),
},
{
key: 'discount',
header: 'Descuento',
render: (line) => (
<div className="text-right text-gray-600">
{line.discount > 0 ? `${line.discount}%` : '-'}
</div>
),
},
{
key: 'tax',
header: 'Impuesto',
render: (line) => (
<div className="text-right text-gray-600">
{formatCurrency(line.amountTax)}
</div>
),
},
{
key: 'total',
header: 'Total',
render: (line) => (
<div className="text-right font-semibold text-gray-900">
{formatCurrency(line.amountTotal)}
</div>
),
},
];
const canEdit = order?.status === 'draft';
const canConfirm = order?.status === 'draft';
const canCancel = order?.status === 'draft' || order?.status === 'sent';
const canCreateInvoice = order?.status === 'sale' && order?.invoiceStatus !== 'invoiced';
const canDelete = order?.status === 'draft';
if (isLoading) {
return (
<div className="flex h-96 items-center justify-center">
<Spinner size="lg" />
</div>
);
}
if (error || !order) {
return (
<div className="p-6">
<ErrorEmptyState
title="Pedido no encontrado"
description="No se pudo cargar la informacion del pedido de venta."
onRetry={refresh}
/>
</div>
);
}
return (
<div className="space-y-6 p-6">
<Breadcrumbs
items={[
{ label: 'Ventas', href: '/sales' },
{ label: 'Pedidos', href: '/sales/orders' },
{ label: order.name },
]}
/>
{/* Header */}
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-4">
<Button variant="ghost" size="sm" onClick={() => navigate('/sales/orders')}>
<ArrowLeft className="mr-2 h-4 w-4" />
Volver
</Button>
<div className="flex items-center gap-3">
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-blue-100">
<ShoppingCart className="h-6 w-6 text-blue-600" />
</div>
<div>
<h1 className="text-2xl font-bold text-gray-900">{order.name}</h1>
{order.clientOrderRef && (
<p className="text-sm text-gray-500">Ref. Cliente: {order.clientOrderRef}</p>
)}
</div>
<StatusBadge status={order.status} />
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
<Button variant="outline" onClick={refresh} disabled={isProcessing}>
<RefreshCw className={`mr-2 h-4 w-4 ${isProcessing ? 'animate-spin' : ''}`} />
Actualizar
</Button>
{canEdit && (
<Button variant="outline" onClick={() => navigate(`/sales/orders/${id}/edit`)}>
<Edit className="mr-2 h-4 w-4" />
Editar
</Button>
)}
{canConfirm && (
<Button variant="primary" onClick={() => setShowConfirmModal(true)}>
<CheckCircle className="mr-2 h-4 w-4" />
Confirmar
</Button>
)}
{canCreateInvoice && (
<Button onClick={() => setShowInvoiceModal(true)}>
<FileText className="mr-2 h-4 w-4" />
Crear Factura
</Button>
)}
{canCancel && (
<Button variant="outline" onClick={() => setShowCancelModal(true)}>
<XCircle className="mr-2 h-4 w-4" />
Cancelar
</Button>
)}
{canDelete && (
<Button variant="danger" onClick={() => setShowDeleteModal(true)}>
<Trash2 className="mr-2 h-4 w-4" />
Eliminar
</Button>
)}
</div>
</div>
{/* Status Cards */}
<div className="grid gap-4 sm:grid-cols-3">
<Card>
<CardContent className="p-4">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-blue-100">
<FileText className="h-5 w-5 text-blue-600" />
</div>
<div>
<div className="text-sm text-gray-500">Facturacion</div>
<InvoiceStatusBadge status={order.invoiceStatus} />
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-amber-100">
<Truck className="h-5 w-5 text-amber-600" />
</div>
<div>
<div className="text-sm text-gray-500">Entrega</div>
<DeliveryStatusBadge status={order.deliveryStatus} />
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-green-100">
<DollarSign className="h-5 w-5 text-green-600" />
</div>
<div>
<div className="text-sm text-gray-500">Total</div>
<div className="text-lg font-bold text-green-600">
{formatCurrency(order.amountTotal, order.currencyCode)}
</div>
</div>
</div>
</CardContent>
</Card>
</div>
{/* Main Content - Two Columns */}
<div className="grid gap-6 lg:grid-cols-3">
{/* Left Column - Order Info */}
<div className="space-y-6 lg:col-span-1">
{/* Customer Info */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<User className="h-5 w-5" />
Cliente
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-3">
<div>
<div className="font-medium text-gray-900">{order.partnerName || order.partnerId}</div>
</div>
{order.salesTeamName && (
<div className="flex items-center gap-2 text-sm text-gray-500">
<Building2 className="h-4 w-4" />
<span>{order.salesTeamName}</span>
</div>
)}
{order.userName && (
<div className="flex items-center gap-2 text-sm text-gray-500">
<User className="h-4 w-4" />
<span>Vendedor: {order.userName}</span>
</div>
)}
</div>
</CardContent>
</Card>
{/* Dates */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Calendar className="h-5 w-5" />
Fechas
</CardTitle>
</CardHeader>
<CardContent>
<dl className="space-y-3">
<div className="flex justify-between">
<dt className="text-sm text-gray-500">Fecha pedido</dt>
<dd className="text-sm font-medium">{formatDate(order.orderDate, 'short')}</dd>
</div>
{order.validityDate && (
<div className="flex justify-between">
<dt className="text-sm text-gray-500">Vigencia</dt>
<dd className="text-sm font-medium">{formatDate(order.validityDate, 'short')}</dd>
</div>
)}
{order.commitmentDate && (
<div className="flex justify-between">
<dt className="text-sm text-gray-500">Fecha compromiso</dt>
<dd className="text-sm font-medium">{formatDate(order.commitmentDate, 'short')}</dd>
</div>
)}
{order.confirmedAt && (
<div className="flex justify-between">
<dt className="text-sm text-gray-500">Confirmado</dt>
<dd className="text-sm font-medium">{formatDate(order.confirmedAt, 'short')}</dd>
</div>
)}
</dl>
</CardContent>
</Card>
{/* Payment & Pricing */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<DollarSign className="h-5 w-5" />
Condiciones
</CardTitle>
</CardHeader>
<CardContent>
<dl className="space-y-3">
<div className="flex justify-between">
<dt className="text-sm text-gray-500">Moneda</dt>
<dd className="text-sm font-medium">{order.currencyCode || 'MXN'}</dd>
</div>
{order.pricelistName && (
<div className="flex justify-between">
<dt className="text-sm text-gray-500">Lista de precios</dt>
<dd className="text-sm font-medium">{order.pricelistName}</dd>
</div>
)}
<div className="flex justify-between">
<dt className="text-sm text-gray-500">Politica facturacion</dt>
<dd className="text-sm font-medium">
{order.invoicePolicy === 'order' ? 'Al confirmar' : 'Al entregar'}
</dd>
</div>
</dl>
</CardContent>
</Card>
{/* Company Info */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Building2 className="h-5 w-5" />
Empresa
</CardTitle>
</CardHeader>
<CardContent>
<dl className="space-y-3">
<div>
<dt className="text-sm text-gray-500">Empresa</dt>
<dd className="text-sm font-medium">{order.companyName || order.companyId}</dd>
</div>
</dl>
</CardContent>
</Card>
</div>
{/* Right Column - Lines & Totals */}
<div className="space-y-6 lg:col-span-2">
{/* Order Lines */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Package className="h-5 w-5" />
Lineas del pedido
</CardTitle>
</CardHeader>
<CardContent>
{order.lines && order.lines.length > 0 ? (
<DataTable
data={order.lines}
columns={lineColumns}
isLoading={false}
/>
) : (
<div className="py-8 text-center text-gray-500">
No hay lineas en este pedido
</div>
)}
</CardContent>
</Card>
{/* Totals */}
<Card>
<CardHeader>
<CardTitle>Resumen</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-3">
<div className="flex justify-between text-sm">
<span className="text-gray-500">Subtotal</span>
<span className="font-medium">{formatCurrency(order.amountUntaxed, order.currencyCode)}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-500">Impuestos</span>
<span className="font-medium">{formatCurrency(order.amountTax, order.currencyCode)}</span>
</div>
<div className="border-t pt-3">
<div className="flex justify-between">
<span className="text-lg font-semibold text-gray-900">Total</span>
<span className="text-lg font-bold text-green-600">
{formatCurrency(order.amountTotal, order.currencyCode)}
</span>
</div>
</div>
</div>
</CardContent>
</Card>
{/* Notes */}
{(order.notes || order.termsConditions) && (
<Card>
<CardHeader>
<CardTitle>Notas y condiciones</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
{order.notes && (
<div>
<dt className="text-sm font-medium text-gray-500">Notas</dt>
<dd className="mt-1 whitespace-pre-wrap text-sm text-gray-900">{order.notes}</dd>
</div>
)}
{order.termsConditions && (
<div>
<dt className="text-sm font-medium text-gray-500">Terminos y condiciones</dt>
<dd className="mt-1 whitespace-pre-wrap text-sm text-gray-900">{order.termsConditions}</dd>
</div>
)}
</div>
</CardContent>
</Card>
)}
{/* System Info */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Clock className="h-5 w-5" />
Informacion del sistema
</CardTitle>
</CardHeader>
<CardContent>
<dl className="grid gap-4 sm:grid-cols-2">
<div>
<dt className="text-sm text-gray-500">ID</dt>
<dd className="font-mono text-sm text-gray-900">{order.id}</dd>
</div>
<div>
<dt className="text-sm text-gray-500">Tenant ID</dt>
<dd className="font-mono text-sm text-gray-900">{order.tenantId}</dd>
</div>
<div>
<dt className="text-sm text-gray-500">Creado</dt>
<dd className="text-sm text-gray-900">{formatDate(order.createdAt, 'full')}</dd>
</div>
{order.pickingId && (
<div>
<dt className="text-sm text-gray-500">ID Entrega</dt>
<dd className="font-mono text-sm text-gray-900">{order.pickingId}</dd>
</div>
)}
</dl>
</CardContent>
</Card>
</div>
</div>
{/* Modals */}
<ConfirmModal
isOpen={showDeleteModal}
onClose={() => setShowDeleteModal(false)}
onConfirm={handleDelete}
title="Eliminar pedido"
message={`¿Estas seguro de que deseas eliminar el pedido ${order.name}? Esta accion no se puede deshacer.`}
variant="danger"
confirmText="Eliminar"
isLoading={isProcessing}
/>
<ConfirmModal
isOpen={showConfirmModal}
onClose={() => setShowConfirmModal(false)}
onConfirm={handleConfirm}
title="Confirmar pedido"
message={`¿Confirmar el pedido ${order.name}? Esta accion reservara el inventario y el pedido pasara a estado Confirmado.`}
variant="success"
confirmText="Confirmar"
isLoading={isProcessing}
/>
<ConfirmModal
isOpen={showCancelModal}
onClose={() => setShowCancelModal(false)}
onConfirm={handleCancel}
title="Cancelar pedido"
message={`¿Cancelar el pedido ${order.name}? Esta accion no se puede deshacer.`}
variant="warning"
confirmText="Cancelar pedido"
isLoading={isProcessing}
/>
<ConfirmModal
isOpen={showInvoiceModal}
onClose={() => setShowInvoiceModal(false)}
onConfirm={handleCreateInvoice}
title="Crear factura"
message={`¿Crear factura para el pedido ${order.name}? Total: ${formatCurrency(order.amountTotal, order.currencyCode)}`}
variant="info"
confirmText="Crear factura"
isLoading={isProcessing}
/>
</div>
);
}
export default SalesOrderDetailPage;

View File

@ -0,0 +1,847 @@
import { useState, useEffect } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import {
ArrowLeft,
Save,
Plus,
Trash2,
ShoppingCart,
User,
Calendar,
FileText,
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 { useSalesOrder, useSalesOrders } from '@features/sales/hooks';
import { usePartners } from '@features/partners/hooks';
import type {
UpdateSalesOrderDto,
CreateSalesOrderLineDto,
UpdateSalesOrderLineDto,
InvoicePolicy,
} from '@features/sales/types';
import { formatCurrency } from '@shared/utils/formatters';
interface LineItemFormData {
id?: string;
productId: string;
productName: string;
description: string;
quantity: number;
uomId: string;
uomName: string;
priceUnit: number;
discount: number;
taxIds: string[];
isNew?: boolean;
isModified?: boolean;
isDeleted?: boolean;
}
interface FormData {
partnerId: string;
clientOrderRef: string;
orderDate: string;
validityDate: string;
commitmentDate: string;
currencyId: string;
pricelistId: string;
paymentTermId: string;
salesTeamId: string;
invoicePolicy: InvoicePolicy;
notes: string;
termsConditions: string;
}
interface FormErrors {
partnerId?: string;
orderDate?: string;
currencyId?: string;
lines?: string;
}
export function SalesOrderEditPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { showToast } = useToast();
// Hooks
const { order, isLoading: orderLoading, error: orderError, refresh, addLine, updateLine, removeLine } = useSalesOrder(id || null);
const { updateOrder } = useSalesOrders({ autoFetch: false });
const { partners, isLoading: partnersLoading } = usePartners({ isCustomer: true, limit: 100 });
// Form state
const [formData, setFormData] = useState<FormData>({
partnerId: '',
clientOrderRef: '',
orderDate: '',
validityDate: '',
commitmentDate: '',
currencyId: '',
pricelistId: '',
paymentTermId: '',
salesTeamId: '',
invoicePolicy: 'order',
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 order loads
useEffect(() => {
if (order) {
setFormData({
partnerId: order.partnerId,
clientOrderRef: order.clientOrderRef || '',
orderDate: order.orderDate?.split('T')[0] ?? '',
validityDate: order.validityDate?.split('T')[0] || '',
commitmentDate: order.commitmentDate?.split('T')[0] || '',
currencyId: order.currencyId,
pricelistId: order.pricelistId || '',
paymentTermId: order.paymentTermId || '',
salesTeamId: order.salesTeamId || '',
invoicePolicy: order.invoicePolicy,
notes: order.notes || '',
termsConditions: order.termsConditions || '',
});
// Load existing lines
if (order.lines) {
setLineItems(
order.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,
taxIds: line.taxIds,
isNew: false,
isModified: false,
isDeleted: false,
}))
);
}
}
}, [order]);
// Check if order is editable (only draft orders can be edited)
const isEditable = order?.status === 'draft';
// Redirect if not editable
useEffect(() => {
if (order && !isEditable) {
showToast({
type: 'warning',
title: 'No editable',
message: `El pedido ${order.name} no puede ser editado porque su estado es "${order.status}".`,
});
navigate(`/sales/orders/${id}`);
}
}, [order, 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 = <K extends keyof LineItemFormData>(index: number, field: K, value: LineItemFormData[K]) => {
setLineItems((prev) => {
const updated = [...prev];
const currentItem = updated[index];
if (!currentItem) return prev;
updated[index] = {
...currentItem,
[field]: value,
isModified: !currentItem.isNew,
};
return updated;
});
setHasChanges(true);
};
const addNewLine = () => {
setLineItems((prev) => [
...prev,
{
productId: '',
productName: '',
description: '',
quantity: 1,
uomId: '',
uomName: '',
priceUnit: 0,
discount: 0,
taxIds: [],
isNew: true,
isModified: false,
isDeleted: false,
},
]);
setHasChanges(true);
};
const markLineForDeletion = (index: number) => {
setLineItems((prev) => {
const updated = [...prev];
const currentItem = updated[index];
if (!currentItem) return prev;
if (currentItem.isNew) {
// Remove new lines immediately
return updated.filter((_, i) => i !== index);
}
// Mark existing lines for deletion
updated[index] = { ...currentItem, isDeleted: true };
return updated;
});
setHasChanges(true);
};
const restoreLine = (index: number) => {
setLineItems((prev) => {
const updated = [...prev];
const currentItem = updated[index];
if (!currentItem) return prev;
updated[index] = { ...currentItem, 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.orderDate) {
newErrors.orderDate = 'La fecha de pedido 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 pedido';
} 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 order header
const orderUpdate: UpdateSalesOrderDto = {
partnerId: formData.partnerId,
clientOrderRef: formData.clientOrderRef || null,
orderDate: formData.orderDate,
validityDate: formData.validityDate || null,
commitmentDate: formData.commitmentDate || null,
currencyId: formData.currencyId,
pricelistId: formData.pricelistId || null,
paymentTermId: formData.paymentTermId || null,
salesTeamId: formData.salesTeamId || null,
invoicePolicy: formData.invoicePolicy,
notes: formData.notes || null,
termsConditions: formData.termsConditions || null,
};
await updateOrder(id, orderUpdate);
// 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 removeLine(line.id);
}
}
// Update existing lines
for (const line of linesToUpdate) {
if (line.id) {
const updateData: UpdateSalesOrderLineDto = {
description: line.description,
quantity: line.quantity,
uomId: line.uomId,
priceUnit: line.priceUnit,
discount: line.discount,
taxIds: line.taxIds,
};
await updateLine(line.id, updateData);
}
}
// Add new lines
for (const line of linesToAdd) {
const createData: CreateSalesOrderLineDto = {
productId: line.productId,
description: line.description,
quantity: line.quantity,
uomId: line.uomId,
priceUnit: line.priceUnit,
discount: line.discount,
taxIds: line.taxIds,
};
await addLine(createData);
}
showToast({
type: 'success',
title: 'Pedido actualizado',
message: 'Los cambios han sido guardados exitosamente.',
});
navigate(`/sales/orders/${id}`);
} catch (err) {
const message = err instanceof Error ? err.message : 'Error al actualizar el pedido';
setSubmitError(message);
showToast({
type: 'error',
title: 'Error',
message,
});
} finally {
setIsSubmitting(false);
}
};
// Loading state
if (orderLoading) {
return (
<div className="flex h-96 items-center justify-center">
<Spinner size="lg" />
</div>
);
}
// Error state
if (orderError || !order) {
return (
<div className="p-6">
<ErrorEmptyState
title="Pedido no encontrado"
description="No se pudo cargar la informacion del pedido de venta."
onRetry={refresh}
/>
</div>
);
}
const totals = calculateTotals();
return (
<div className="space-y-6 p-6">
<Breadcrumbs
items={[
{ label: 'Ventas', href: '/sales' },
{ label: 'Pedidos', href: '/sales/orders' },
{ label: order.name, href: `/sales/orders/${id}` },
{ label: 'Editar' },
]}
/>
<div className="flex items-center gap-4">
<Button variant="ghost" size="sm" onClick={() => navigate(`/sales/orders/${id}`)}>
<ArrowLeft className="mr-2 h-4 w-4" />
Volver
</Button>
<div>
<h1 className="text-2xl font-bold text-gray-900">
Editar Pedido de Venta {order.name}
</h1>
<p className="text-sm text-gray-500">
Modifica la informacion del pedido
</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>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Referencia del Cliente
</label>
<input
type="text"
value={formData.clientOrderRef}
onChange={(e) => handleFieldChange('clientOrderRef', e.target.value)}
placeholder="Numero de orden del 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>
{/* Order Details */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Calendar className="h-5 w-5" />
Detalles del Pedido
</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 Pedido *
</label>
<input
type="date"
value={formData.orderDate}
onChange={(e) => handleFieldChange('orderDate', e.target.value)}
className={`w-full rounded-md border px-3 py-2 focus:outline-none focus:ring-1 ${
errors.orderDate
? 'border-red-300 focus:border-red-500 focus:ring-red-500'
: 'border-gray-300 focus:border-blue-500 focus:ring-blue-500'
}`}
/>
{errors.orderDate && (
<p className="mt-1 text-sm text-red-600">{errors.orderDate}</p>
)}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Fecha de Vigencia
</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">
Fecha Compromiso
</label>
<input
type="date"
value={formData.commitmentDate}
onChange={(e) => handleFieldChange('commitmentDate', 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>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<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>
<label className="block text-sm font-medium text-gray-700 mb-1">
Politica de Facturacion
</label>
<select
value={formData.invoicePolicy}
onChange={(e) => handleFieldChange('invoicePolicy', e.target.value as InvoicePolicy)}
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"
>
<option value="order">Facturar al pedido</option>
<option value="delivery">Facturar a la entrega</option>
</select>
</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 del Pedido
</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 el pedido</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 Internas
</label>
<textarea
value={formData.notes}
onChange={(e) => handleFieldChange('notes', e.target.value)}
rows={3}
placeholder="Notas internas sobre el pedido..."
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>
)}
{/* Order Summary */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<ShoppingCart 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/orders/${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 los pedidos en estado borrador pueden ser editados
</p>
</div>
</CardContent>
</Card>
</div>
</div>
</form>
</div>
);
}
export default SalesOrderEditPage;

View File

@ -291,7 +291,7 @@ export function SalesOrdersPage() {
<RefreshCw className={`mr-2 h-4 w-4 ${isLoading ? 'animate-spin' : ''}`} />
Actualizar
</Button>
<Button>
<Button onClick={() => navigate('/sales/orders/new')}>
<Plus className="mr-2 h-4 w-4" />
Nuevo pedido
</Button>

View File

@ -1,2 +1,8 @@
export { SalesOrdersPage, default as SalesOrdersPageDefault } from './SalesOrdersPage';
export { SalesOrderCreatePage, default as SalesOrderCreatePageDefault } from './SalesOrderCreatePage';
export { SalesOrderDetailPage, default as SalesOrderDetailPageDefault } from './SalesOrderDetailPage';
export { SalesOrderEditPage, default as SalesOrderEditPageDefault } from './SalesOrderEditPage';
export { QuotationsPage, default as QuotationsPageDefault } from './QuotationsPage';
export { QuotationDetailPage, default as QuotationDetailPageDefault } from './QuotationDetailPage';
export { QuotationCreatePage, default as QuotationCreatePageDefault } from './QuotationCreatePage';
export { QuotationEditPage, default as QuotationEditPageDefault } from './QuotationEditPage';