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 <noreply@anthropic.com>
This commit is contained in:
rckrdmrd 2026-01-18 10:03:15 -06:00
parent 28c8bbb768
commit 32f2c06264
6 changed files with 1374 additions and 0 deletions

View File

@ -0,0 +1,11 @@
export {
useSalesOrders,
useSalesOrder,
useQuotations,
useQuotation,
} from './useSales';
export type {
UseSalesOrdersOptions,
UseQuotationsOptions,
} from './useSales';

View File

@ -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<SalesOrder[]>([]);
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<string | null>(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<SalesOrder | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(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<Quotation[]>([]);
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<string | null>(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<Quotation | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(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,
};
}

View File

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

View File

@ -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<QuotationStatus, string> = {
draft: 'Borrador',
sent: 'Enviada',
accepted: 'Aceptada',
rejected: 'Rechazada',
expired: 'Expirada',
converted: 'Convertida',
};
const statusColors: Record<QuotationStatus, string> = {
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<QuotationStatus | ''>('');
const [searchTerm, setSearchTerm] = useState('');
const [dateFrom, setDateFrom] = useState('');
const [dateTo, setDateTo] = useState('');
const [quotationToSend, setQuotationToSend] = useState<Quotation | null>(null);
const [quotationToAccept, setQuotationToAccept] = useState<Quotation | null>(null);
const [quotationToReject, setQuotationToReject] = useState<Quotation | null>(null);
const [quotationToConvert, setQuotationToConvert] = useState<Quotation | null>(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: <Eye className="h-4 w-4" />,
onClick: () => console.log('View', quotation.id),
},
{
key: 'download',
label: 'Descargar PDF',
icon: <Download className="h-4 w-4" />,
onClick: () => downloadPdf(quotation.id, `cotizacion-${quotation.quotationNumber}.pdf`),
},
];
if (quotation.status === 'draft') {
items.push({
key: 'send',
label: 'Enviar al cliente',
icon: <Send className="h-4 w-4" />,
onClick: () => setQuotationToSend(quotation),
});
}
if (quotation.status === 'sent') {
items.push({
key: 'accept',
label: 'Marcar aceptada',
icon: <CheckCircle className="h-4 w-4" />,
onClick: () => setQuotationToAccept(quotation),
});
items.push({
key: 'reject',
label: 'Marcar rechazada',
icon: <XCircle className="h-4 w-4" />,
danger: true,
onClick: () => setQuotationToReject(quotation),
});
}
if (quotation.status === 'accepted') {
items.push({
key: 'convert',
label: 'Convertir a pedido',
icon: <ShoppingCart className="h-4 w-4" />,
onClick: () => setQuotationToConvert(quotation),
});
}
return items;
};
const columns: Column<Quotation>[] = [
{
key: 'number',
header: 'Cotizacion',
render: (quotation) => (
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-purple-50">
<FileText className="h-5 w-5 text-purple-600" />
</div>
<div>
<div className="font-medium text-gray-900">{quotation.quotationNumber}</div>
<div className="text-sm text-gray-500">
{formatDate(quotation.quotationDate, 'short')}
</div>
</div>
</div>
),
},
{
key: 'partner',
header: 'Cliente',
render: (quotation) => (
<span className="text-sm text-gray-900">{quotation.partnerName || quotation.partnerId}</span>
),
},
{
key: 'validity',
header: 'Vigencia',
render: (quotation) => {
if (!quotation.validityDate) return <span className="text-gray-400">-</span>;
const validityDate = new Date(quotation.validityDate);
const today = new Date();
const isExpired = validityDate < today && quotation.status === 'sent';
return (
<div className="flex items-center gap-1">
<Clock className={`h-4 w-4 ${isExpired ? 'text-red-500' : 'text-gray-400'}`} />
<span className={`text-sm ${isExpired ? 'text-red-600' : 'text-gray-600'}`}>
{formatDate(quotation.validityDate, 'short')}
</span>
</div>
);
},
},
{
key: 'amount',
header: 'Total',
sortable: true,
render: (quotation) => (
<div className="text-right">
<div className="font-medium text-gray-900">
${formatCurrency(quotation.amountTotal)}
</div>
{quotation.currencyCode && quotation.currencyCode !== 'MXN' && (
<div className="text-xs text-gray-500">{quotation.currencyCode}</div>
)}
</div>
),
},
{
key: 'status',
header: 'Estado',
render: (quotation) => (
<span className={`inline-flex rounded-full px-2 py-1 text-xs font-medium ${statusColors[quotation.status as QuotationStatus]}`}>
{statusLabels[quotation.status as QuotationStatus]}
</span>
),
},
{
key: 'actions',
header: '',
render: (quotation) => (
<Dropdown
trigger={
<button className="rounded p-1 hover:bg-gray-100">
<MoreVertical className="h-4 w-4 text-gray-500" />
</button>
}
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 (
<div className="p-6">
<ErrorEmptyState onRetry={refresh} />
</div>
);
}
return (
<div className="space-y-6 p-6">
<Breadcrumbs items={[
{ label: 'Ventas', href: '/sales' },
{ label: 'Cotizaciones' },
]} />
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900">Cotizaciones</h1>
<p className="text-sm text-gray-500">
Gestiona propuestas comerciales y conversiones a pedidos
</p>
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={refresh} disabled={isLoading}>
<RefreshCw className={`mr-2 h-4 w-4 ${isLoading ? 'animate-spin' : ''}`} />
Actualizar
</Button>
<Button>
<Plus className="mr-2 h-4 w-4" />
Nueva cotizacion
</Button>
</div>
</div>
{/* Summary Stats */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-4">
<Card className="cursor-pointer hover:shadow-md transition-shadow" onClick={() => setSelectedStatus('draft')}>
<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-gray-100">
<FileText className="h-5 w-5 text-gray-600" />
</div>
<div>
<div className="text-sm text-gray-500">Borradores</div>
<div className="text-xl font-bold text-gray-900">{draftCount}</div>
</div>
</div>
</CardContent>
</Card>
<Card className="cursor-pointer hover:shadow-md transition-shadow" onClick={() => setSelectedStatus('sent')}>
<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">
<Send className="h-5 w-5 text-blue-600" />
</div>
<div>
<div className="text-sm text-gray-500">Enviadas</div>
<div className="text-xl font-bold text-blue-600">{sentCount}</div>
</div>
</div>
</CardContent>
</Card>
<Card className="cursor-pointer hover:shadow-md transition-shadow" onClick={() => setSelectedStatus('accepted')}>
<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">
<CheckCircle className="h-5 w-5 text-green-600" />
</div>
<div>
<div className="text-sm text-gray-500">Aceptadas</div>
<div className="text-xl font-bold text-green-600">{acceptedCount}</div>
</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-purple-100">
<DollarSign className="h-5 w-5 text-purple-600" />
</div>
<div>
<div className="text-sm text-gray-500">Valor Total</div>
<div className="text-xl font-bold text-purple-600">${formatCurrency(totalAmount)}</div>
</div>
</div>
</CardContent>
</Card>
</div>
<Card>
<CardHeader>
<CardTitle>Lista de Cotizaciones</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
{/* Filters */}
<div className="flex flex-wrap items-center gap-4">
<div className="relative flex-1 min-w-[200px]">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
<input
type="text"
placeholder="Buscar cotizaciones..."
value={searchTerm}
onChange={(e) => 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"
/>
</div>
<select
value={selectedStatus}
onChange={(e) => setSelectedStatus(e.target.value as QuotationStatus | '')}
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"
>
<option value="">Todos los estados</option>
{Object.entries(statusLabels).map(([value, label]) => (
<option key={value} value={value}>{label}</option>
))}
</select>
<div className="flex items-center gap-2">
<Calendar className="h-4 w-4 text-gray-400" />
<input
type="date"
value={dateFrom}
onChange={(e) => 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"
/>
<span className="text-gray-400">-</span>
<input
type="date"
value={dateTo}
onChange={(e) => 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"
/>
</div>
{(selectedStatus || searchTerm || dateFrom || dateTo) && (
<Button
variant="ghost"
size="sm"
onClick={() => {
setSelectedStatus('');
setSearchTerm('');
setDateFrom('');
setDateTo('');
}}
>
Limpiar filtros
</Button>
)}
</div>
{/* Table */}
{quotations.length === 0 && !isLoading ? (
<NoDataEmptyState
entityName="cotizaciones"
/>
) : (
<DataTable
data={quotations}
columns={columns}
isLoading={isLoading}
pagination={{
page,
totalPages,
total,
limit: 20,
onPageChange: setPage,
}}
/>
)}
</div>
</CardContent>
</Card>
{/* Send Quotation Modal */}
<ConfirmModal
isOpen={!!quotationToSend}
onClose={() => setQuotationToSend(null)}
onConfirm={handleSend}
title="Enviar cotizacion"
message={`¿Enviar la cotizacion ${quotationToSend?.quotationNumber} al cliente?`}
variant="info"
confirmText="Enviar"
/>
{/* Accept Quotation Modal */}
<ConfirmModal
isOpen={!!quotationToAccept}
onClose={() => setQuotationToAccept(null)}
onConfirm={handleAccept}
title="Aceptar cotizacion"
message={`¿Marcar la cotizacion ${quotationToAccept?.quotationNumber} como aceptada?`}
variant="success"
confirmText="Aceptar"
/>
{/* Reject Quotation Modal */}
<ConfirmModal
isOpen={!!quotationToReject}
onClose={() => setQuotationToReject(null)}
onConfirm={handleReject}
title="Rechazar cotizacion"
message={`¿Marcar la cotizacion ${quotationToReject?.quotationNumber} como rechazada?`}
variant="danger"
confirmText="Rechazar"
/>
{/* Convert to Order Modal */}
<ConfirmModal
isOpen={!!quotationToConvert}
onClose={() => setQuotationToConvert(null)}
onConfirm={handleConvert}
title="Convertir a pedido"
message={`¿Convertir la cotizacion ${quotationToConvert?.quotationNumber} en un pedido de venta?`}
variant="success"
confirmText="Convertir"
/>
</div>
);
}
export default QuotationsPage;

View File

@ -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<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',
};
// 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<SalesOrderStatus | ''>('');
const [searchTerm, setSearchTerm] = useState('');
const [dateFrom, setDateFrom] = useState('');
const [dateTo, setDateTo] = useState('');
const [orderToConfirm, setOrderToConfirm] = useState<SalesOrder | null>(null);
const [orderToCancel, setOrderToCancel] = useState<SalesOrder | null>(null);
const [orderToInvoice, setOrderToInvoice] = useState<SalesOrder | null>(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: <Eye className="h-4 w-4" />,
onClick: () => console.log('View', order.id),
},
];
if (order.status === 'draft') {
items.push({
key: 'confirm',
label: 'Confirmar pedido',
icon: <CheckCircle className="h-4 w-4" />,
onClick: () => setOrderToConfirm(order),
});
items.push({
key: 'cancel',
label: 'Cancelar',
icon: <XCircle className="h-4 w-4" />,
danger: true,
onClick: () => setOrderToCancel(order),
});
}
if (order.status === 'sale' && order.invoiceStatus !== 'invoiced') {
items.push({
key: 'invoice',
label: 'Crear factura',
icon: <FileText className="h-4 w-4" />,
onClick: () => setOrderToInvoice(order),
});
}
return items;
};
const columns: Column<SalesOrder>[] = [
{
key: 'name',
header: 'Pedido',
render: (order) => (
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-blue-50">
<ShoppingCart className="h-5 w-5 text-blue-600" />
</div>
<div>
<div className="font-medium text-gray-900">{order.name}</div>
{order.clientOrderRef && (
<div className="text-sm text-gray-500">Ref: {order.clientOrderRef}</div>
)}
</div>
</div>
),
},
{
key: 'partner',
header: 'Cliente',
render: (order) => (
<div>
<div className="text-sm text-gray-900">{order.partnerName || order.partnerId}</div>
{order.salesTeamName && (
<div className="text-xs text-gray-500">{order.salesTeamName}</div>
)}
</div>
),
},
{
key: 'date',
header: 'Fecha',
sortable: true,
render: (order) => (
<span className="text-sm text-gray-600">
{formatDate(order.orderDate, 'short')}
</span>
),
},
{
key: 'amount',
header: 'Total',
sortable: true,
render: (order) => (
<div className="text-right">
<div className="font-medium text-gray-900">
${formatCurrency(order.amountTotal)}
</div>
{order.currencyCode && order.currencyCode !== 'MXN' && (
<div className="text-xs text-gray-500">{order.currencyCode}</div>
)}
</div>
),
},
{
key: 'status',
header: 'Estado',
render: (order) => (
<span className={`inline-flex rounded-full px-2 py-1 text-xs font-medium ${statusColors[order.status]}`}>
{statusLabels[order.status]}
</span>
),
},
{
key: 'invoiceStatus',
header: 'Factura',
render: (order) => (
<span className={`inline-flex rounded-full px-2 py-1 text-xs font-medium ${invoiceStatusColors[order.invoiceStatus]}`}>
{invoiceStatusLabels[order.invoiceStatus]}
</span>
),
},
{
key: 'deliveryStatus',
header: 'Entrega',
render: (order) => (
<span className={`inline-flex rounded-full px-2 py-1 text-xs font-medium ${deliveryStatusColors[order.deliveryStatus]}`}>
{deliveryStatusLabels[order.deliveryStatus]}
</span>
),
},
{
key: 'actions',
header: '',
render: (order) => (
<Dropdown
trigger={
<button className="rounded p-1 hover:bg-gray-100">
<MoreVertical className="h-4 w-4 text-gray-500" />
</button>
}
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 (
<div className="p-6">
<ErrorEmptyState onRetry={refresh} />
</div>
);
}
return (
<div className="space-y-6 p-6">
<Breadcrumbs items={[
{ label: 'Ventas', href: '/sales' },
{ label: 'Pedidos de Venta' },
]} />
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900">Pedidos de Venta</h1>
<p className="text-sm text-gray-500">
Gestiona pedidos, facturacion y entregas
</p>
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={refresh} disabled={isLoading}>
<RefreshCw className={`mr-2 h-4 w-4 ${isLoading ? 'animate-spin' : ''}`} />
Actualizar
</Button>
<Button>
<Plus className="mr-2 h-4 w-4" />
Nuevo pedido
</Button>
</div>
</div>
{/* Summary Stats */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-4">
<Card className="cursor-pointer hover:shadow-md transition-shadow" onClick={() => setSelectedStatus('draft')}>
<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-gray-100">
<ShoppingCart className="h-5 w-5 text-gray-600" />
</div>
<div>
<div className="text-sm text-gray-500">Borradores</div>
<div className="text-xl font-bold text-gray-900">{draftCount}</div>
</div>
</div>
</CardContent>
</Card>
<Card className="cursor-pointer hover:shadow-md transition-shadow" onClick={() => setSelectedStatus('sale')}>
<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">
<CheckCircle className="h-5 w-5 text-green-600" />
</div>
<div>
<div className="text-sm text-gray-500">Confirmados</div>
<div className="text-xl font-bold text-green-600">{confirmedCount}</div>
</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">Por Entregar</div>
<div className="text-xl font-bold text-amber-600">{pendingDelivery}</div>
</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-blue-100">
<DollarSign className="h-5 w-5 text-blue-600" />
</div>
<div>
<div className="text-sm text-gray-500">Total Ventas</div>
<div className="text-xl font-bold text-blue-600">${formatCurrency(totalAmount)}</div>
</div>
</div>
</CardContent>
</Card>
</div>
<Card>
<CardHeader>
<CardTitle>Lista de Pedidos</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
{/* Filters */}
<div className="flex flex-wrap items-center gap-4">
<div className="relative flex-1 min-w-[200px]">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
<input
type="text"
placeholder="Buscar pedidos..."
value={searchTerm}
onChange={(e) => 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"
/>
</div>
<select
value={selectedStatus}
onChange={(e) => setSelectedStatus(e.target.value as SalesOrderStatus | '')}
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"
>
<option value="">Todos los estados</option>
{Object.entries(statusLabels).map(([value, label]) => (
<option key={value} value={value}>{label}</option>
))}
</select>
<div className="flex items-center gap-2">
<Calendar className="h-4 w-4 text-gray-400" />
<input
type="date"
value={dateFrom}
onChange={(e) => 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"
/>
<span className="text-gray-400">-</span>
<input
type="date"
value={dateTo}
onChange={(e) => 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"
/>
</div>
{(selectedStatus || searchTerm || dateFrom || dateTo) && (
<Button
variant="ghost"
size="sm"
onClick={() => {
setSelectedStatus('');
setSearchTerm('');
setDateFrom('');
setDateTo('');
}}
>
Limpiar filtros
</Button>
)}
</div>
{/* Table */}
{orders.length === 0 && !isLoading ? (
<NoDataEmptyState
entityName="pedidos de venta"
/>
) : (
<DataTable
data={orders}
columns={columns}
isLoading={isLoading}
pagination={{
page,
totalPages,
total,
limit: 20,
onPageChange: setPage,
}}
/>
)}
</div>
</CardContent>
</Card>
{/* Confirm Order Modal */}
<ConfirmModal
isOpen={!!orderToConfirm}
onClose={() => 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 */}
<ConfirmModal
isOpen={!!orderToCancel}
onClose={() => 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 */}
<ConfirmModal
isOpen={!!orderToInvoice}
onClose={() => 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"
/>
</div>
);
}
export default SalesOrdersPage;

2
src/pages/sales/index.ts Normal file
View File

@ -0,0 +1,2 @@
export { SalesOrdersPage, default as SalesOrdersPageDefault } from './SalesOrdersPage';
export { QuotationsPage, default as QuotationsPageDefault } from './QuotationsPage';