From 32f2c062642e8dc4999784616e8c395cf3c95bfc Mon Sep 17 00:00:00 2001 From: rckrdmrd Date: Sun, 18 Jan 2026 10:03:15 -0600 Subject: [PATCH] feat(sales): Add complete sales frontend pages and hooks (MGN-013) New pages: - SalesOrdersPage: Sales orders list with confirm/cancel/invoice actions - QuotationsPage: Quotations management with send/accept/reject/convert actions New hooks: - useSalesOrders: Fetch and manage sales orders with CRUD and actions - useSalesOrder: Single order management with line items - useQuotations: Quotations lifecycle management - useQuotation: Single quotation fetching Features: - Status filters (draft, sent, sale, done, cancelled) - Date range filtering - Invoice and delivery status tracking - PDF download for quotations - Convert quotation to sales order Co-Authored-By: Claude Opus 4.5 --- src/features/sales/hooks/index.ts | 11 + src/features/sales/hooks/useSales.ts | 397 ++++++++++++++++++++++ src/features/sales/index.ts | 1 + src/pages/sales/QuotationsPage.tsx | 483 +++++++++++++++++++++++++++ src/pages/sales/SalesOrdersPage.tsx | 480 ++++++++++++++++++++++++++ src/pages/sales/index.ts | 2 + 6 files changed, 1374 insertions(+) create mode 100644 src/features/sales/hooks/index.ts create mode 100644 src/features/sales/hooks/useSales.ts create mode 100644 src/pages/sales/QuotationsPage.tsx create mode 100644 src/pages/sales/SalesOrdersPage.tsx create mode 100644 src/pages/sales/index.ts diff --git a/src/features/sales/hooks/index.ts b/src/features/sales/hooks/index.ts new file mode 100644 index 0000000..864c45b --- /dev/null +++ b/src/features/sales/hooks/index.ts @@ -0,0 +1,11 @@ +export { + useSalesOrders, + useSalesOrder, + useQuotations, + useQuotation, +} from './useSales'; + +export type { + UseSalesOrdersOptions, + UseQuotationsOptions, +} from './useSales'; diff --git a/src/features/sales/hooks/useSales.ts b/src/features/sales/hooks/useSales.ts new file mode 100644 index 0000000..18a86e1 --- /dev/null +++ b/src/features/sales/hooks/useSales.ts @@ -0,0 +1,397 @@ +import { useState, useEffect, useCallback } from 'react'; +import { salesApi } from '../api/sales.api'; +import type { + SalesOrder, + SalesOrderFilters, + CreateSalesOrderDto, + UpdateSalesOrderDto, + CreateSalesOrderLineDto, + UpdateSalesOrderLineDto, + Quotation, + QuotationFilters, + CreateQuotationDto, +} from '../types'; + +// ==================== Sales Orders Hook ==================== + +export interface UseSalesOrdersOptions extends SalesOrderFilters { + autoFetch?: boolean; +} + +export function useSalesOrders(options: UseSalesOrdersOptions = {}) { + const { autoFetch = true, ...filters } = options; + const [orders, setOrders] = useState([]); + const [total, setTotal] = useState(0); + const [page, setPage] = useState(filters.page || 1); + const [totalPages, setTotalPages] = useState(1); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + const fetchOrders = useCallback(async () => { + setIsLoading(true); + setError(null); + try { + const response = await salesApi.getOrders({ ...filters, page }); + setOrders(response.data); + setTotal(response.meta.total); + setTotalPages(response.meta.totalPages); + } catch (err) { + setError(err instanceof Error ? err.message : 'Error al cargar pedidos de venta'); + } finally { + setIsLoading(false); + } + }, [filters.companyId, filters.partnerId, filters.status, filters.invoiceStatus, filters.deliveryStatus, filters.dateFrom, filters.dateTo, filters.search, filters.limit, filters.sortBy, filters.sortOrder, page]); + + useEffect(() => { + if (autoFetch) { + fetchOrders(); + } + }, [fetchOrders, autoFetch]); + + const createOrder = async (data: CreateSalesOrderDto) => { + setIsLoading(true); + try { + const newOrder = await salesApi.createOrder(data); + await fetchOrders(); + return newOrder; + } catch (err) { + setError(err instanceof Error ? err.message : 'Error al crear pedido'); + throw err; + } finally { + setIsLoading(false); + } + }; + + const updateOrder = async (id: string, data: UpdateSalesOrderDto) => { + setIsLoading(true); + try { + const updated = await salesApi.updateOrder(id, data); + await fetchOrders(); + return updated; + } catch (err) { + setError(err instanceof Error ? err.message : 'Error al actualizar pedido'); + throw err; + } finally { + setIsLoading(false); + } + }; + + const deleteOrder = async (id: string) => { + setIsLoading(true); + try { + await salesApi.deleteOrder(id); + await fetchOrders(); + } catch (err) { + setError(err instanceof Error ? err.message : 'Error al eliminar pedido'); + throw err; + } finally { + setIsLoading(false); + } + }; + + const confirmOrder = async (id: string) => { + setIsLoading(true); + try { + await salesApi.confirmOrder(id); + await fetchOrders(); + } catch (err) { + setError(err instanceof Error ? err.message : 'Error al confirmar pedido'); + throw err; + } finally { + setIsLoading(false); + } + }; + + const cancelOrder = async (id: string) => { + setIsLoading(true); + try { + await salesApi.cancelOrder(id); + await fetchOrders(); + } catch (err) { + setError(err instanceof Error ? err.message : 'Error al cancelar pedido'); + throw err; + } finally { + setIsLoading(false); + } + }; + + const createInvoice = async (id: string) => { + setIsLoading(true); + try { + const result = await salesApi.createInvoice(id); + await fetchOrders(); + return result; + } catch (err) { + setError(err instanceof Error ? err.message : 'Error al crear factura'); + throw err; + } finally { + setIsLoading(false); + } + }; + + return { + orders, + total, + page, + totalPages, + isLoading, + error, + setPage, + refresh: fetchOrders, + createOrder, + updateOrder, + deleteOrder, + confirmOrder, + cancelOrder, + createInvoice, + }; +} + +// ==================== Single Sales Order Hook ==================== + +export function useSalesOrder(orderId: string | null) { + const [order, setOrder] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + const fetchOrder = useCallback(async () => { + if (!orderId) { + setOrder(null); + return; + } + + setIsLoading(true); + setError(null); + try { + const data = await salesApi.getOrderById(orderId); + setOrder(data); + } catch (err) { + setError(err instanceof Error ? err.message : 'Error al cargar pedido'); + } finally { + setIsLoading(false); + } + }, [orderId]); + + useEffect(() => { + fetchOrder(); + }, [fetchOrder]); + + const addLine = async (data: CreateSalesOrderLineDto) => { + if (!orderId) return; + setIsLoading(true); + try { + await salesApi.addLine(orderId, data); + await fetchOrder(); + } catch (err) { + setError(err instanceof Error ? err.message : 'Error al agregar linea'); + throw err; + } finally { + setIsLoading(false); + } + }; + + const updateLine = async (lineId: string, data: UpdateSalesOrderLineDto) => { + if (!orderId) return; + setIsLoading(true); + try { + await salesApi.updateLine(orderId, lineId, data); + await fetchOrder(); + } catch (err) { + setError(err instanceof Error ? err.message : 'Error al actualizar linea'); + throw err; + } finally { + setIsLoading(false); + } + }; + + const removeLine = async (lineId: string) => { + if (!orderId) return; + setIsLoading(true); + try { + await salesApi.removeLine(orderId, lineId); + await fetchOrder(); + } catch (err) { + setError(err instanceof Error ? err.message : 'Error al eliminar linea'); + throw err; + } finally { + setIsLoading(false); + } + }; + + return { + order, + isLoading, + error, + refresh: fetchOrder, + addLine, + updateLine, + removeLine, + }; +} + +// ==================== Quotations Hook ==================== + +export interface UseQuotationsOptions extends QuotationFilters { + autoFetch?: boolean; +} + +export function useQuotations(options: UseQuotationsOptions = {}) { + const { autoFetch = true, ...filters } = options; + const [quotations, setQuotations] = useState([]); + const [total, setTotal] = useState(0); + const [page, setPage] = useState(filters.page || 1); + const [totalPages, setTotalPages] = useState(1); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + const fetchQuotations = useCallback(async () => { + setIsLoading(true); + setError(null); + try { + const response = await salesApi.getQuotations({ ...filters, page }); + setQuotations(response.data); + setTotal(response.meta.total); + setTotalPages(response.meta.totalPages); + } catch (err) { + setError(err instanceof Error ? err.message : 'Error al cargar cotizaciones'); + } finally { + setIsLoading(false); + } + }, [filters.companyId, filters.partnerId, filters.status, filters.dateFrom, filters.dateTo, filters.search, filters.limit, page]); + + useEffect(() => { + if (autoFetch) { + fetchQuotations(); + } + }, [fetchQuotations, autoFetch]); + + const createQuotation = async (data: CreateQuotationDto) => { + setIsLoading(true); + try { + const newQuotation = await salesApi.createQuotation(data); + await fetchQuotations(); + return newQuotation; + } catch (err) { + setError(err instanceof Error ? err.message : 'Error al crear cotizacion'); + throw err; + } finally { + setIsLoading(false); + } + }; + + const sendQuotation = async (id: string) => { + setIsLoading(true); + try { + await salesApi.sendQuotation(id); + await fetchQuotations(); + } catch (err) { + setError(err instanceof Error ? err.message : 'Error al enviar cotizacion'); + throw err; + } finally { + setIsLoading(false); + } + }; + + const acceptQuotation = async (id: string) => { + setIsLoading(true); + try { + await salesApi.acceptQuotation(id); + await fetchQuotations(); + } catch (err) { + setError(err instanceof Error ? err.message : 'Error al aceptar cotizacion'); + throw err; + } finally { + setIsLoading(false); + } + }; + + const rejectQuotation = async (id: string, reason?: string) => { + setIsLoading(true); + try { + await salesApi.rejectQuotation(id, reason); + await fetchQuotations(); + } catch (err) { + setError(err instanceof Error ? err.message : 'Error al rechazar cotizacion'); + throw err; + } finally { + setIsLoading(false); + } + }; + + const convertToOrder = async (id: string) => { + setIsLoading(true); + try { + const order = await salesApi.convertToOrder(id); + await fetchQuotations(); + return order; + } catch (err) { + setError(err instanceof Error ? err.message : 'Error al convertir a pedido'); + throw err; + } finally { + setIsLoading(false); + } + }; + + const downloadPdf = async (id: string, fileName?: string) => { + try { + await salesApi.downloadQuotationPdf(id, fileName); + } catch (err) { + setError(err instanceof Error ? err.message : 'Error al descargar PDF'); + throw err; + } + }; + + return { + quotations, + total, + page, + totalPages, + isLoading, + error, + setPage, + refresh: fetchQuotations, + createQuotation, + sendQuotation, + acceptQuotation, + rejectQuotation, + convertToOrder, + downloadPdf, + }; +} + +// ==================== Single Quotation Hook ==================== + +export function useQuotation(quotationId: string | null) { + const [quotation, setQuotation] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + const fetchQuotation = useCallback(async () => { + if (!quotationId) { + setQuotation(null); + return; + } + + setIsLoading(true); + setError(null); + try { + const data = await salesApi.getQuotationById(quotationId); + setQuotation(data); + } catch (err) { + setError(err instanceof Error ? err.message : 'Error al cargar cotizacion'); + } finally { + setIsLoading(false); + } + }, [quotationId]); + + useEffect(() => { + fetchQuotation(); + }, [fetchQuotation]); + + return { + quotation, + isLoading, + error, + refresh: fetchQuotation, + }; +} diff --git a/src/features/sales/index.ts b/src/features/sales/index.ts index 197fa01..1d50e1a 100644 --- a/src/features/sales/index.ts +++ b/src/features/sales/index.ts @@ -1,2 +1,3 @@ export * from './api/sales.api'; export * from './types'; +export * from './hooks'; diff --git a/src/pages/sales/QuotationsPage.tsx b/src/pages/sales/QuotationsPage.tsx new file mode 100644 index 0000000..4499a79 --- /dev/null +++ b/src/pages/sales/QuotationsPage.tsx @@ -0,0 +1,483 @@ +import { useState } from 'react'; +import { + FileText, + Plus, + MoreVertical, + Eye, + Send, + CheckCircle, + XCircle, + ShoppingCart, + Download, + Calendar, + DollarSign, + RefreshCw, + Search, + Clock, +} from 'lucide-react'; +import { Button } from '@components/atoms/Button'; +import { Card, CardHeader, CardTitle, CardContent } from '@components/molecules/Card'; +import { DataTable, type Column } from '@components/organisms/DataTable'; +import { Dropdown, type DropdownItem } from '@components/organisms/Dropdown'; +import { Breadcrumbs } from '@components/organisms/Breadcrumbs'; +import { ConfirmModal } from '@components/organisms/Modal'; +import { NoDataEmptyState, ErrorEmptyState } from '@components/templates/EmptyState'; +import { useQuotations } from '@features/sales/hooks'; +import type { Quotation } from '@features/sales/types'; +import { formatDate, formatNumber } from '@utils/formatters'; + +type QuotationStatus = 'draft' | 'sent' | 'accepted' | 'rejected' | 'expired' | 'converted'; + +const statusLabels: Record = { + draft: 'Borrador', + sent: 'Enviada', + accepted: 'Aceptada', + rejected: 'Rechazada', + expired: 'Expirada', + converted: 'Convertida', +}; + +const statusColors: Record = { + draft: 'bg-gray-100 text-gray-700', + sent: 'bg-blue-100 text-blue-700', + accepted: 'bg-green-100 text-green-700', + rejected: 'bg-red-100 text-red-700', + expired: 'bg-amber-100 text-amber-700', + converted: 'bg-purple-100 text-purple-700', +}; + +// Helper function to format currency with 2 decimals +const formatCurrency = (value: number): string => { + return formatNumber(value, 'es-MX', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); +}; + +export function QuotationsPage() { + const [selectedStatus, setSelectedStatus] = useState(''); + const [searchTerm, setSearchTerm] = useState(''); + const [dateFrom, setDateFrom] = useState(''); + const [dateTo, setDateTo] = useState(''); + const [quotationToSend, setQuotationToSend] = useState(null); + const [quotationToAccept, setQuotationToAccept] = useState(null); + const [quotationToReject, setQuotationToReject] = useState(null); + const [quotationToConvert, setQuotationToConvert] = useState(null); + + const { + quotations, + total, + page, + totalPages, + isLoading, + error, + setPage, + refresh, + sendQuotation, + acceptQuotation, + rejectQuotation, + convertToOrder, + downloadPdf, + } = useQuotations({ + status: selectedStatus || undefined, + search: searchTerm || undefined, + dateFrom: dateFrom || undefined, + dateTo: dateTo || undefined, + limit: 20, + }); + + const getActionsMenu = (quotation: Quotation): DropdownItem[] => { + const items: DropdownItem[] = [ + { + key: 'view', + label: 'Ver detalle', + icon: , + onClick: () => console.log('View', quotation.id), + }, + { + key: 'download', + label: 'Descargar PDF', + icon: , + onClick: () => downloadPdf(quotation.id, `cotizacion-${quotation.quotationNumber}.pdf`), + }, + ]; + + if (quotation.status === 'draft') { + items.push({ + key: 'send', + label: 'Enviar al cliente', + icon: , + onClick: () => setQuotationToSend(quotation), + }); + } + + if (quotation.status === 'sent') { + items.push({ + key: 'accept', + label: 'Marcar aceptada', + icon: , + onClick: () => setQuotationToAccept(quotation), + }); + items.push({ + key: 'reject', + label: 'Marcar rechazada', + icon: , + danger: true, + onClick: () => setQuotationToReject(quotation), + }); + } + + if (quotation.status === 'accepted') { + items.push({ + key: 'convert', + label: 'Convertir a pedido', + icon: , + onClick: () => setQuotationToConvert(quotation), + }); + } + + return items; + }; + + const columns: Column[] = [ + { + key: 'number', + header: 'Cotizacion', + render: (quotation) => ( +
+
+ +
+
+
{quotation.quotationNumber}
+
+ {formatDate(quotation.quotationDate, 'short')} +
+
+
+ ), + }, + { + key: 'partner', + header: 'Cliente', + render: (quotation) => ( + {quotation.partnerName || quotation.partnerId} + ), + }, + { + key: 'validity', + header: 'Vigencia', + render: (quotation) => { + if (!quotation.validityDate) return -; + const validityDate = new Date(quotation.validityDate); + const today = new Date(); + const isExpired = validityDate < today && quotation.status === 'sent'; + return ( +
+ + + {formatDate(quotation.validityDate, 'short')} + +
+ ); + }, + }, + { + key: 'amount', + header: 'Total', + sortable: true, + render: (quotation) => ( +
+
+ ${formatCurrency(quotation.amountTotal)} +
+ {quotation.currencyCode && quotation.currencyCode !== 'MXN' && ( +
{quotation.currencyCode}
+ )} +
+ ), + }, + { + key: 'status', + header: 'Estado', + render: (quotation) => ( + + {statusLabels[quotation.status as QuotationStatus]} + + ), + }, + { + key: 'actions', + header: '', + render: (quotation) => ( + + + + } + items={getActionsMenu(quotation)} + align="right" + /> + ), + }, + ]; + + const handleSend = async () => { + if (quotationToSend) { + await sendQuotation(quotationToSend.id); + setQuotationToSend(null); + } + }; + + const handleAccept = async () => { + if (quotationToAccept) { + await acceptQuotation(quotationToAccept.id); + setQuotationToAccept(null); + } + }; + + const handleReject = async () => { + if (quotationToReject) { + await rejectQuotation(quotationToReject.id); + setQuotationToReject(null); + } + }; + + const handleConvert = async () => { + if (quotationToConvert) { + await convertToOrder(quotationToConvert.id); + setQuotationToConvert(null); + } + }; + + // Calculate summary stats + const draftCount = quotations.filter(q => q.status === 'draft').length; + const sentCount = quotations.filter(q => q.status === 'sent').length; + const acceptedCount = quotations.filter(q => q.status === 'accepted').length; + const totalAmount = quotations.reduce((sum, q) => sum + q.amountTotal, 0); + + if (error) { + return ( +
+ +
+ ); + } + + return ( +
+ + +
+
+

Cotizaciones

+

+ Gestiona propuestas comerciales y conversiones a pedidos +

+
+
+ + +
+
+ + {/* Summary Stats */} +
+ setSelectedStatus('draft')}> + +
+
+ +
+
+
Borradores
+
{draftCount}
+
+
+
+
+ + setSelectedStatus('sent')}> + +
+
+ +
+
+
Enviadas
+
{sentCount}
+
+
+
+
+ + setSelectedStatus('accepted')}> + +
+
+ +
+
+
Aceptadas
+
{acceptedCount}
+
+
+
+
+ + + +
+
+ +
+
+
Valor Total
+
${formatCurrency(totalAmount)}
+
+
+
+
+
+ + + + Lista de Cotizaciones + + +
+ {/* Filters */} +
+
+ + setSearchTerm(e.target.value)} + className="w-full rounded-md border border-gray-300 py-2 pl-10 pr-4 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500" + /> +
+ + + +
+ + setDateFrom(e.target.value)} + className="rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500" + /> + - + setDateTo(e.target.value)} + className="rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500" + /> +
+ + {(selectedStatus || searchTerm || dateFrom || dateTo) && ( + + )} +
+ + {/* Table */} + {quotations.length === 0 && !isLoading ? ( + + ) : ( + + )} +
+
+
+ + {/* Send Quotation Modal */} + setQuotationToSend(null)} + onConfirm={handleSend} + title="Enviar cotizacion" + message={`¿Enviar la cotizacion ${quotationToSend?.quotationNumber} al cliente?`} + variant="info" + confirmText="Enviar" + /> + + {/* Accept Quotation Modal */} + setQuotationToAccept(null)} + onConfirm={handleAccept} + title="Aceptar cotizacion" + message={`¿Marcar la cotizacion ${quotationToAccept?.quotationNumber} como aceptada?`} + variant="success" + confirmText="Aceptar" + /> + + {/* Reject Quotation Modal */} + setQuotationToReject(null)} + onConfirm={handleReject} + title="Rechazar cotizacion" + message={`¿Marcar la cotizacion ${quotationToReject?.quotationNumber} como rechazada?`} + variant="danger" + confirmText="Rechazar" + /> + + {/* Convert to Order Modal */} + setQuotationToConvert(null)} + onConfirm={handleConvert} + title="Convertir a pedido" + message={`¿Convertir la cotizacion ${quotationToConvert?.quotationNumber} en un pedido de venta?`} + variant="success" + confirmText="Convertir" + /> +
+ ); +} + +export default QuotationsPage; diff --git a/src/pages/sales/SalesOrdersPage.tsx b/src/pages/sales/SalesOrdersPage.tsx new file mode 100644 index 0000000..3202e3c --- /dev/null +++ b/src/pages/sales/SalesOrdersPage.tsx @@ -0,0 +1,480 @@ +import { useState } from 'react'; +import { + ShoppingCart, + Plus, + MoreVertical, + Eye, + CheckCircle, + XCircle, + FileText, + Calendar, + DollarSign, + Truck, + RefreshCw, + Search, +} from 'lucide-react'; +import { Button } from '@components/atoms/Button'; +import { Card, CardHeader, CardTitle, CardContent } from '@components/molecules/Card'; +import { DataTable, type Column } from '@components/organisms/DataTable'; +import { Dropdown, type DropdownItem } from '@components/organisms/Dropdown'; +import { Breadcrumbs } from '@components/organisms/Breadcrumbs'; +import { ConfirmModal } from '@components/organisms/Modal'; +import { NoDataEmptyState, ErrorEmptyState } from '@components/templates/EmptyState'; +import { useSalesOrders } from '@features/sales/hooks'; +import type { SalesOrder, SalesOrderStatus, InvoiceStatus, DeliveryStatus } from '@features/sales/types'; +import { formatDate, formatNumber } from '@utils/formatters'; + +const statusLabels: Record = { + draft: 'Borrador', + sent: 'Enviado', + sale: 'Confirmado', + done: 'Completado', + cancelled: 'Cancelado', +}; + +const statusColors: Record = { + 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 = { + pending: 'Pendiente', + partial: 'Parcial', + invoiced: 'Facturado', +}; + +const invoiceStatusColors: Record = { + pending: 'bg-amber-100 text-amber-700', + partial: 'bg-blue-100 text-blue-700', + invoiced: 'bg-green-100 text-green-700', +}; + +const deliveryStatusLabels: Record = { + pending: 'Pendiente', + partial: 'Parcial', + delivered: 'Entregado', +}; + +const deliveryStatusColors: Record = { + pending: 'bg-amber-100 text-amber-700', + partial: 'bg-blue-100 text-blue-700', + delivered: 'bg-green-100 text-green-700', +}; + +// Helper function to format currency with 2 decimals +const formatCurrency = (value: number): string => { + return formatNumber(value, 'es-MX', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); +}; + +export function SalesOrdersPage() { + const [selectedStatus, setSelectedStatus] = useState(''); + const [searchTerm, setSearchTerm] = useState(''); + const [dateFrom, setDateFrom] = useState(''); + const [dateTo, setDateTo] = useState(''); + const [orderToConfirm, setOrderToConfirm] = useState(null); + const [orderToCancel, setOrderToCancel] = useState(null); + const [orderToInvoice, setOrderToInvoice] = useState(null); + + const { + orders, + total, + page, + totalPages, + isLoading, + error, + setPage, + refresh, + confirmOrder, + cancelOrder, + createInvoice, + } = useSalesOrders({ + status: selectedStatus || undefined, + search: searchTerm || undefined, + dateFrom: dateFrom || undefined, + dateTo: dateTo || undefined, + limit: 20, + }); + + const getActionsMenu = (order: SalesOrder): DropdownItem[] => { + const items: DropdownItem[] = [ + { + key: 'view', + label: 'Ver detalle', + icon: , + onClick: () => console.log('View', order.id), + }, + ]; + + if (order.status === 'draft') { + items.push({ + key: 'confirm', + label: 'Confirmar pedido', + icon: , + onClick: () => setOrderToConfirm(order), + }); + items.push({ + key: 'cancel', + label: 'Cancelar', + icon: , + danger: true, + onClick: () => setOrderToCancel(order), + }); + } + + if (order.status === 'sale' && order.invoiceStatus !== 'invoiced') { + items.push({ + key: 'invoice', + label: 'Crear factura', + icon: , + onClick: () => setOrderToInvoice(order), + }); + } + + return items; + }; + + const columns: Column[] = [ + { + key: 'name', + header: 'Pedido', + render: (order) => ( +
+
+ +
+
+
{order.name}
+ {order.clientOrderRef && ( +
Ref: {order.clientOrderRef}
+ )} +
+
+ ), + }, + { + key: 'partner', + header: 'Cliente', + render: (order) => ( +
+
{order.partnerName || order.partnerId}
+ {order.salesTeamName && ( +
{order.salesTeamName}
+ )} +
+ ), + }, + { + key: 'date', + header: 'Fecha', + sortable: true, + render: (order) => ( + + {formatDate(order.orderDate, 'short')} + + ), + }, + { + key: 'amount', + header: 'Total', + sortable: true, + render: (order) => ( +
+
+ ${formatCurrency(order.amountTotal)} +
+ {order.currencyCode && order.currencyCode !== 'MXN' && ( +
{order.currencyCode}
+ )} +
+ ), + }, + { + key: 'status', + header: 'Estado', + render: (order) => ( + + {statusLabels[order.status]} + + ), + }, + { + key: 'invoiceStatus', + header: 'Factura', + render: (order) => ( + + {invoiceStatusLabels[order.invoiceStatus]} + + ), + }, + { + key: 'deliveryStatus', + header: 'Entrega', + render: (order) => ( + + {deliveryStatusLabels[order.deliveryStatus]} + + ), + }, + { + key: 'actions', + header: '', + render: (order) => ( + + + + } + items={getActionsMenu(order)} + align="right" + /> + ), + }, + ]; + + const handleConfirm = async () => { + if (orderToConfirm) { + await confirmOrder(orderToConfirm.id); + setOrderToConfirm(null); + } + }; + + const handleCancel = async () => { + if (orderToCancel) { + await cancelOrder(orderToCancel.id); + setOrderToCancel(null); + } + }; + + const handleCreateInvoice = async () => { + if (orderToInvoice) { + await createInvoice(orderToInvoice.id); + setOrderToInvoice(null); + } + }; + + // Calculate summary stats + const draftCount = orders.filter(o => o.status === 'draft').length; + const confirmedCount = orders.filter(o => o.status === 'sale').length; + const totalAmount = orders.reduce((sum, o) => sum + o.amountTotal, 0); + const pendingDelivery = orders.filter(o => o.deliveryStatus === 'pending' && o.status === 'sale').length; + + if (error) { + return ( +
+ +
+ ); + } + + return ( +
+ + +
+
+

Pedidos de Venta

+

+ Gestiona pedidos, facturacion y entregas +

+
+
+ + +
+
+ + {/* Summary Stats */} +
+ setSelectedStatus('draft')}> + +
+
+ +
+
+
Borradores
+
{draftCount}
+
+
+
+
+ + setSelectedStatus('sale')}> + +
+
+ +
+
+
Confirmados
+
{confirmedCount}
+
+
+
+
+ + + +
+
+ +
+
+
Por Entregar
+
{pendingDelivery}
+
+
+
+
+ + + +
+
+ +
+
+
Total Ventas
+
${formatCurrency(totalAmount)}
+
+
+
+
+
+ + + + Lista de Pedidos + + +
+ {/* Filters */} +
+
+ + setSearchTerm(e.target.value)} + className="w-full rounded-md border border-gray-300 py-2 pl-10 pr-4 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500" + /> +
+ + + +
+ + setDateFrom(e.target.value)} + className="rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500" + /> + - + setDateTo(e.target.value)} + className="rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500" + /> +
+ + {(selectedStatus || searchTerm || dateFrom || dateTo) && ( + + )} +
+ + {/* Table */} + {orders.length === 0 && !isLoading ? ( + + ) : ( + + )} +
+
+
+ + {/* Confirm Order Modal */} + setOrderToConfirm(null)} + onConfirm={handleConfirm} + title="Confirmar pedido" + message={`¿Confirmar el pedido ${orderToConfirm?.name}? Esta accion lo enviara al cliente y reservara el stock.`} + variant="success" + confirmText="Confirmar" + /> + + {/* Cancel Order Modal */} + setOrderToCancel(null)} + onConfirm={handleCancel} + title="Cancelar pedido" + message={`¿Cancelar el pedido ${orderToCancel?.name}? Esta accion no se puede deshacer.`} + variant="danger" + confirmText="Cancelar pedido" + /> + + {/* Create Invoice Modal */} + setOrderToInvoice(null)} + onConfirm={handleCreateInvoice} + title="Crear factura" + message={`¿Crear factura para el pedido ${orderToInvoice?.name}? Total: $${orderToInvoice ? formatCurrency(orderToInvoice.amountTotal) : '0.00'}`} + variant="info" + confirmText="Crear factura" + /> +
+ ); +} + +export default SalesOrdersPage; diff --git a/src/pages/sales/index.ts b/src/pages/sales/index.ts new file mode 100644 index 0000000..f9f4a57 --- /dev/null +++ b/src/pages/sales/index.ts @@ -0,0 +1,2 @@ +export { SalesOrdersPage, default as SalesOrdersPageDefault } from './SalesOrdersPage'; +export { QuotationsPage, default as QuotationsPageDefault } from './QuotationsPage';