From 36ba16e2a2fd6243f998f0b364cf6263c03a1a26 Mon Sep 17 00:00:00 2001 From: Adrian Flores Cortes Date: Tue, 3 Feb 2026 19:51:13 -0600 Subject: [PATCH] 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 --- src/app/router/routes.tsx | 84 +- src/features/sales/api/sales.api.ts | 36 + .../sales/components/QuotationActions.tsx | 287 ++++++ .../sales/components/QuotationStatusBadge.tsx | 24 + .../sales/components/SalesOrderForm.tsx | 418 +++++++++ .../components/SalesOrderLineItemForm.tsx | 265 ++++++ .../sales/components/SalesOrderLineItems.tsx | 284 ++++++ .../components/SalesOrderStatusBadge.tsx | 22 + .../sales/components/SalesOrderSummary.tsx | 76 ++ src/features/sales/components/index.ts | 10 + src/features/sales/index.ts | 1 + src/features/sales/types/sales.types.ts | 26 + src/pages/sales/QuotationCreatePage.tsx | 638 +++++++++++++ src/pages/sales/QuotationDetailPage.tsx | 354 ++++++++ src/pages/sales/QuotationEditPage.tsx | 780 ++++++++++++++++ src/pages/sales/QuotationsPage.tsx | 2 +- src/pages/sales/SalesOrderCreatePage.tsx | 782 ++++++++++++++++ src/pages/sales/SalesOrderDetailPage.tsx | 726 +++++++++++++++ src/pages/sales/SalesOrderEditPage.tsx | 847 ++++++++++++++++++ src/pages/sales/SalesOrdersPage.tsx | 2 +- src/pages/sales/index.ts | 6 + 21 files changed, 5667 insertions(+), 3 deletions(-) create mode 100644 src/features/sales/components/QuotationActions.tsx create mode 100644 src/features/sales/components/QuotationStatusBadge.tsx create mode 100644 src/features/sales/components/SalesOrderForm.tsx create mode 100644 src/features/sales/components/SalesOrderLineItemForm.tsx create mode 100644 src/features/sales/components/SalesOrderLineItems.tsx create mode 100644 src/features/sales/components/SalesOrderStatusBadge.tsx create mode 100644 src/features/sales/components/SalesOrderSummary.tsx create mode 100644 src/features/sales/components/index.ts create mode 100644 src/pages/sales/QuotationCreatePage.tsx create mode 100644 src/pages/sales/QuotationDetailPage.tsx create mode 100644 src/pages/sales/QuotationEditPage.tsx create mode 100644 src/pages/sales/SalesOrderCreatePage.tsx create mode 100644 src/pages/sales/SalesOrderDetailPage.tsx create mode 100644 src/pages/sales/SalesOrderEditPage.tsx diff --git a/src/app/router/routes.tsx b/src/app/router/routes.tsx index 2260d2c..61a0438 100644 --- a/src/app/router/routes.tsx +++ b/src/app/router/routes.tsx @@ -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([ ), }, + // Sales routes + { + path: '/sales', + element: , + }, + { + path: '/sales/orders', + element: ( + + + + ), + }, + { + path: '/sales/orders/new', + element: ( + + + + ), + }, + { + path: '/sales/orders/:id', + element: ( + + + + ), + }, + { + path: '/sales/orders/:id/edit', + element: ( + + + + ), + }, + // Quotation routes + { + path: '/sales/quotations', + element: ( + + + + ), + }, + { + path: '/sales/quotations/new', + element: ( + + + + ), + }, + { + path: '/sales/quotations/:id', + element: ( + + + + ), + }, + { + path: '/sales/quotations/:id/edit', + element: ( + + + + ), + }, { path: '/sales/*', element: ( -
Módulo de Ventas - En desarrollo
+
Seccion de Ventas - En desarrollo
), }, diff --git a/src/features/sales/api/sales.api.ts b/src/features/sales/api/sales.api.ts index f5173b5..4e83fcb 100644 --- a/src/features/sales/api/sales.api.ts +++ b/src/features/sales/api/sales.api.ts @@ -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 => { + const response = await api.patch(`${QUOTATIONS_URL}/${id}`, data); + return response.data; + }, + + // Delete quotation + deleteQuotation: async (id: string): Promise => { + await api.delete(`${QUOTATIONS_URL}/${id}`); + }, + + // ==================== Quotation Lines ==================== + + // Add line to quotation + addQuotationLine: async (quotationId: string, data: CreateQuotationLineDto): Promise => { + const response = await api.post(`${QUOTATIONS_URL}/${quotationId}/lines`, data); + return response.data; + }, + + // Update quotation line + updateQuotationLine: async (quotationId: string, lineId: string, data: UpdateQuotationLineDto): Promise => { + const response = await api.patch(`${QUOTATIONS_URL}/${quotationId}/lines/${lineId}`, data); + return response.data; + }, + + // Remove quotation line + removeQuotationLine: async (quotationId: string, lineId: string): Promise => { + await api.delete(`${QUOTATIONS_URL}/${quotationId}/lines/${lineId}`); + }, + + // ==================== Quotation Actions ==================== + // Send quotation sendQuotation: async (id: string): Promise => { const response = await api.post(`${QUOTATIONS_URL}/${id}/send`); diff --git a/src/features/sales/components/QuotationActions.tsx b/src/features/sales/components/QuotationActions.tsx new file mode 100644 index 0000000..a0a93e9 --- /dev/null +++ b/src/features/sales/components/QuotationActions.tsx @@ -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 ( + <> +
+ + + + + {canEdit && ( + + )} + + {canSend && ( + + )} + + {canAccept && ( + + )} + + {canReject && ( + + )} + + {canConvert && ( + + )} + + {canDelete && ( + + )} +
+ + {/* Delete Modal */} + 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 */} + setShowSendModal(false)} + onConfirm={handleSend} + title="Enviar cotizacion" + message={`¿Enviar la cotizacion ${quotation.quotationNumber} al cliente?`} + variant="info" + confirmText="Enviar" + isLoading={localProcessing} + /> + + {/* Accept Modal */} + 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 */} + setShowRejectModal(false)} + onConfirm={handleReject} + title="Rechazar cotizacion" + message={`¿Marcar la cotizacion ${quotation.quotationNumber} como rechazada?`} + variant="warning" + confirmText="Rechazar" + isLoading={localProcessing} + /> + + {/* Convert Modal */} + 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} + /> + + ); +} diff --git a/src/features/sales/components/QuotationStatusBadge.tsx b/src/features/sales/components/QuotationStatusBadge.tsx new file mode 100644 index 0000000..c58499f --- /dev/null +++ b/src/features/sales/components/QuotationStatusBadge.tsx @@ -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 {config.label}; +} diff --git a/src/features/sales/components/SalesOrderForm.tsx b/src/features/sales/components/SalesOrderForm.tsx new file mode 100644 index 0000000..e8eb6c2 --- /dev/null +++ b/src/features/sales/components/SalesOrderForm.tsx @@ -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; + +interface SelectOption { + value: string; + label: string; +} + +interface ProductOption extends SelectOption { + defaultPrice?: number; + defaultUomId?: string; +} + +interface SalesOrderFormProps { + initialData?: SalesOrder; + onSubmit: (data: CreateSalesOrderDto | UpdateSalesOrderDto) => Promise; + 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; + onUpdateLine?: (lineId: string, data: Partial) => Promise; + onRemoveLine?: (lineId: string) => Promise; + 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({ + 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) => { + if (onUpdateLine) { + await onUpdateLine(lineId, data); + } + }; + + const handleRemoveLine = async (lineId: string) => { + if (onRemoveLine) { + await onRemoveLine(lineId); + } + }; + + return ( +
+ {/* Header Section */} +
+

+ Informacion general +

+ +
+ {!isEditing && ( + + setValue('partnerId', value as string)} + placeholder="Buscar cliente..." + searchable + /> + + + + + +
+
+ + {/* Dates Section */} +
+

Fechas

+ +
+ + setValue('orderDate', date || undefined)} + placeholder="Seleccionar fecha..." + /> + + + + setValue('validityDate', date || undefined)} + placeholder="Seleccionar fecha..." + minDate={orderDate} + /> + + + + setValue('commitmentDate', date || undefined)} + placeholder="Seleccionar fecha..." + minDate={orderDate} + /> + +
+
+ + {/* Pricing Section */} +
+

+ Facturacion y precios +

+ +
+ + setValue('pricelistId', value as string)} + placeholder="Seleccionar lista..." + clearable + /> + + + + setValue('salesTeamId', value as string)} + placeholder="Seleccionar equipo..." + clearable + /> + + + +