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:
parent
28c8bbb768
commit
32f2c06264
11
src/features/sales/hooks/index.ts
Normal file
11
src/features/sales/hooks/index.ts
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
export {
|
||||||
|
useSalesOrders,
|
||||||
|
useSalesOrder,
|
||||||
|
useQuotations,
|
||||||
|
useQuotation,
|
||||||
|
} from './useSales';
|
||||||
|
|
||||||
|
export type {
|
||||||
|
UseSalesOrdersOptions,
|
||||||
|
UseQuotationsOptions,
|
||||||
|
} from './useSales';
|
||||||
397
src/features/sales/hooks/useSales.ts
Normal file
397
src/features/sales/hooks/useSales.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -1,2 +1,3 @@
|
|||||||
export * from './api/sales.api';
|
export * from './api/sales.api';
|
||||||
export * from './types';
|
export * from './types';
|
||||||
|
export * from './hooks';
|
||||||
|
|||||||
483
src/pages/sales/QuotationsPage.tsx
Normal file
483
src/pages/sales/QuotationsPage.tsx
Normal 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;
|
||||||
480
src/pages/sales/SalesOrdersPage.tsx
Normal file
480
src/pages/sales/SalesOrdersPage.tsx
Normal 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
2
src/pages/sales/index.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export { SalesOrdersPage, default as SalesOrdersPageDefault } from './SalesOrdersPage';
|
||||||
|
export { QuotationsPage, default as QuotationsPageDefault } from './QuotationsPage';
|
||||||
Loading…
Reference in New Issue
Block a user