feat(purchases): Add Purchasing module frontend (hooks + pages)
- Create purchases hooks (usePurchaseOrders, usePurchaseReceipts) - Create PurchaseOrdersPage with order management and PDF download - Create PurchaseReceiptsPage with receipt validation - Update feature index to export hooks MGN-012 Purchasing frontend implementation Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
836ebaf638
commit
54c14f87c8
11
src/features/purchases/hooks/index.ts
Normal file
11
src/features/purchases/hooks/index.ts
Normal file
@ -0,0 +1,11 @@
|
||||
export {
|
||||
usePurchaseOrders,
|
||||
usePurchaseOrder,
|
||||
usePurchaseReceipts,
|
||||
usePurchaseReceipt,
|
||||
} from './usePurchases';
|
||||
|
||||
export type {
|
||||
UsePurchaseOrdersOptions,
|
||||
UsePurchaseReceiptsOptions,
|
||||
} from './usePurchases';
|
||||
306
src/features/purchases/hooks/usePurchases.ts
Normal file
306
src/features/purchases/hooks/usePurchases.ts
Normal file
@ -0,0 +1,306 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { purchasesApi } from '../api/purchases.api';
|
||||
import type {
|
||||
PurchaseOrder,
|
||||
PurchaseOrderFilters,
|
||||
CreatePurchaseOrderDto,
|
||||
UpdatePurchaseOrderDto,
|
||||
PurchaseReceipt,
|
||||
PurchaseReceiptFilters,
|
||||
CreatePurchaseReceiptDto,
|
||||
} from '../types';
|
||||
|
||||
// ==================== Purchase Orders Hook ====================
|
||||
|
||||
export interface UsePurchaseOrdersOptions extends PurchaseOrderFilters {
|
||||
autoFetch?: boolean;
|
||||
}
|
||||
|
||||
export function usePurchaseOrders(options: UsePurchaseOrdersOptions = {}) {
|
||||
const { autoFetch = true, ...filters } = options;
|
||||
const [orders, setOrders] = useState<PurchaseOrder[]>([]);
|
||||
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 purchasesApi.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 ordenes de compra');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [filters.companyId, filters.partnerId, filters.status, filters.dateFrom, filters.dateTo, filters.search, filters.limit, filters.sortBy, filters.sortOrder, page]);
|
||||
|
||||
useEffect(() => {
|
||||
if (autoFetch) {
|
||||
fetchOrders();
|
||||
}
|
||||
}, [fetchOrders, autoFetch]);
|
||||
|
||||
const createOrder = async (data: CreatePurchaseOrderDto) => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const newOrder = await purchasesApi.createOrder(data);
|
||||
await fetchOrders();
|
||||
return newOrder;
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Error al crear orden de compra');
|
||||
throw err;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const updateOrder = async (id: string, data: UpdatePurchaseOrderDto) => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const updated = await purchasesApi.updateOrder(id, data);
|
||||
await fetchOrders();
|
||||
return updated;
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Error al actualizar orden');
|
||||
throw err;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteOrder = async (id: string) => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await purchasesApi.deleteOrder(id);
|
||||
await fetchOrders();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Error al eliminar orden');
|
||||
throw err;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const confirmOrder = async (id: string) => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await purchasesApi.confirmOrder(id);
|
||||
await fetchOrders();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Error al confirmar orden');
|
||||
throw err;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const cancelOrder = async (id: string) => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await purchasesApi.cancelOrder(id);
|
||||
await fetchOrders();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Error al cancelar orden');
|
||||
throw err;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const downloadPdf = async (id: string, fileName?: string) => {
|
||||
try {
|
||||
await purchasesApi.downloadOrderPdf(id, fileName);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Error al descargar PDF');
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
orders,
|
||||
total,
|
||||
page,
|
||||
totalPages,
|
||||
isLoading,
|
||||
error,
|
||||
setPage,
|
||||
refresh: fetchOrders,
|
||||
createOrder,
|
||||
updateOrder,
|
||||
deleteOrder,
|
||||
confirmOrder,
|
||||
cancelOrder,
|
||||
downloadPdf,
|
||||
};
|
||||
}
|
||||
|
||||
// ==================== Single Purchase Order Hook ====================
|
||||
|
||||
export function usePurchaseOrder(orderId: string | null) {
|
||||
const [order, setOrder] = useState<PurchaseOrder | 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 purchasesApi.getOrderById(orderId);
|
||||
setOrder(data);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Error al cargar orden de compra');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [orderId]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchOrder();
|
||||
}, [fetchOrder]);
|
||||
|
||||
return {
|
||||
order,
|
||||
isLoading,
|
||||
error,
|
||||
refresh: fetchOrder,
|
||||
};
|
||||
}
|
||||
|
||||
// ==================== Purchase Receipts Hook ====================
|
||||
|
||||
export interface UsePurchaseReceiptsOptions extends PurchaseReceiptFilters {
|
||||
autoFetch?: boolean;
|
||||
}
|
||||
|
||||
export function usePurchaseReceipts(options: UsePurchaseReceiptsOptions = {}) {
|
||||
const { autoFetch = true, ...filters } = options;
|
||||
const [receipts, setReceipts] = useState<PurchaseReceipt[]>([]);
|
||||
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 fetchReceipts = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await purchasesApi.getReceipts({ ...filters, page });
|
||||
setReceipts(response.data);
|
||||
setTotal(response.meta.total);
|
||||
setTotalPages(response.meta.totalPages);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Error al cargar recepciones');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [filters.purchaseOrderId, filters.partnerId, filters.status, filters.dateFrom, filters.dateTo, filters.search, filters.limit, page]);
|
||||
|
||||
useEffect(() => {
|
||||
if (autoFetch) {
|
||||
fetchReceipts();
|
||||
}
|
||||
}, [fetchReceipts, autoFetch]);
|
||||
|
||||
const createReceipt = async (data: CreatePurchaseReceiptDto) => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const newReceipt = await purchasesApi.createReceipt(data);
|
||||
await fetchReceipts();
|
||||
return newReceipt;
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Error al crear recepcion');
|
||||
throw err;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const confirmReceipt = async (id: string) => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await purchasesApi.confirmReceipt(id);
|
||||
await fetchReceipts();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Error al confirmar recepcion');
|
||||
throw err;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const cancelReceipt = async (id: string) => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await purchasesApi.cancelReceipt(id);
|
||||
await fetchReceipts();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Error al cancelar recepcion');
|
||||
throw err;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
receipts,
|
||||
total,
|
||||
page,
|
||||
totalPages,
|
||||
isLoading,
|
||||
error,
|
||||
setPage,
|
||||
refresh: fetchReceipts,
|
||||
createReceipt,
|
||||
confirmReceipt,
|
||||
cancelReceipt,
|
||||
};
|
||||
}
|
||||
|
||||
// ==================== Single Purchase Receipt Hook ====================
|
||||
|
||||
export function usePurchaseReceipt(receiptId: string | null) {
|
||||
const [receipt, setReceipt] = useState<PurchaseReceipt | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchReceipt = useCallback(async () => {
|
||||
if (!receiptId) {
|
||||
setReceipt(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await purchasesApi.getReceiptById(receiptId);
|
||||
setReceipt(data);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Error al cargar recepcion');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [receiptId]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchReceipt();
|
||||
}, [fetchReceipt]);
|
||||
|
||||
return {
|
||||
receipt,
|
||||
isLoading,
|
||||
error,
|
||||
refresh: fetchReceipt,
|
||||
};
|
||||
}
|
||||
@ -1,2 +1,3 @@
|
||||
export * from './api/purchases.api';
|
||||
export * from './types';
|
||||
export * from './hooks';
|
||||
|
||||
460
src/pages/purchases/PurchaseOrdersPage.tsx
Normal file
460
src/pages/purchases/PurchaseOrdersPage.tsx
Normal file
@ -0,0 +1,460 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
ShoppingBag,
|
||||
Plus,
|
||||
MoreVertical,
|
||||
Eye,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
Calendar,
|
||||
DollarSign,
|
||||
RefreshCw,
|
||||
Search,
|
||||
FileText,
|
||||
Package,
|
||||
Download,
|
||||
} 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 { usePurchaseOrders } from '@features/purchases/hooks';
|
||||
import type { PurchaseOrder, PurchaseOrderStatus } from '@features/purchases/types';
|
||||
import { formatDate, formatNumber } from '@utils/formatters';
|
||||
|
||||
const statusLabels: Record<PurchaseOrderStatus, string> = {
|
||||
draft: 'Borrador',
|
||||
sent: 'Enviado',
|
||||
confirmed: 'Confirmado',
|
||||
done: 'Completado',
|
||||
cancelled: 'Cancelado',
|
||||
};
|
||||
|
||||
const statusColors: Record<PurchaseOrderStatus, string> = {
|
||||
draft: 'bg-gray-100 text-gray-700',
|
||||
sent: 'bg-blue-100 text-blue-700',
|
||||
confirmed: 'bg-green-100 text-green-700',
|
||||
done: 'bg-purple-100 text-purple-700',
|
||||
cancelled: 'bg-red-100 text-red-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 PurchaseOrdersPage() {
|
||||
const [selectedStatus, setSelectedStatus] = useState<PurchaseOrderStatus | ''>('');
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [dateFrom, setDateFrom] = useState('');
|
||||
const [dateTo, setDateTo] = useState('');
|
||||
const [orderToConfirm, setOrderToConfirm] = useState<PurchaseOrder | null>(null);
|
||||
const [orderToCancel, setOrderToCancel] = useState<PurchaseOrder | null>(null);
|
||||
|
||||
const {
|
||||
orders,
|
||||
total,
|
||||
page,
|
||||
totalPages,
|
||||
isLoading,
|
||||
error,
|
||||
setPage,
|
||||
refresh,
|
||||
confirmOrder,
|
||||
cancelOrder,
|
||||
downloadPdf,
|
||||
} = usePurchaseOrders({
|
||||
status: selectedStatus || undefined,
|
||||
search: searchTerm || undefined,
|
||||
dateFrom: dateFrom || undefined,
|
||||
dateTo: dateTo || undefined,
|
||||
limit: 20,
|
||||
});
|
||||
|
||||
const getActionsMenu = (order: PurchaseOrder): DropdownItem[] => {
|
||||
const items: DropdownItem[] = [
|
||||
{
|
||||
key: 'view',
|
||||
label: 'Ver detalle',
|
||||
icon: <Eye className="h-4 w-4" />,
|
||||
onClick: () => console.log('View', order.id),
|
||||
},
|
||||
{
|
||||
key: 'pdf',
|
||||
label: 'Descargar PDF',
|
||||
icon: <Download className="h-4 w-4" />,
|
||||
onClick: () => downloadPdf(order.id, `OC-${order.name}.pdf`),
|
||||
},
|
||||
];
|
||||
|
||||
if (order.status === 'draft') {
|
||||
items.push({
|
||||
key: 'confirm',
|
||||
label: 'Confirmar orden',
|
||||
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 === 'confirmed') {
|
||||
items.push({
|
||||
key: 'receive',
|
||||
label: 'Recibir productos',
|
||||
icon: <Package className="h-4 w-4" />,
|
||||
onClick: () => console.log('Receive', order.id),
|
||||
});
|
||||
items.push({
|
||||
key: 'invoice',
|
||||
label: 'Crear factura',
|
||||
icon: <FileText className="h-4 w-4" />,
|
||||
onClick: () => console.log('Invoice', order.id),
|
||||
});
|
||||
}
|
||||
|
||||
return items;
|
||||
};
|
||||
|
||||
const columns: Column<PurchaseOrder>[] = [
|
||||
{
|
||||
key: 'name',
|
||||
header: 'Orden',
|
||||
render: (order) => (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-amber-50">
|
||||
<ShoppingBag className="h-5 w-5 text-amber-600" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium text-gray-900">{order.name}</div>
|
||||
{order.ref && (
|
||||
<div className="text-sm text-gray-500">Ref: {order.ref}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'partner',
|
||||
header: 'Proveedor',
|
||||
render: (order) => (
|
||||
<div>
|
||||
<div className="text-sm text-gray-900">{order.partnerName || order.partnerId}</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'date',
|
||||
header: 'Fecha',
|
||||
sortable: true,
|
||||
render: (order) => (
|
||||
<span className="text-sm text-gray-600">
|
||||
{formatDate(order.orderDate, 'short')}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'expectedDate',
|
||||
header: 'Entrega Esperada',
|
||||
render: (order) => (
|
||||
<span className="text-sm text-gray-600">
|
||||
{order.expectedDate ? formatDate(order.expectedDate, '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: 'receiptStatus',
|
||||
header: 'Recepcion',
|
||||
render: (order) => {
|
||||
const status = order.receiptStatus || 'pending';
|
||||
const colors: Record<string, string> = {
|
||||
pending: 'bg-amber-100 text-amber-700',
|
||||
partial: 'bg-blue-100 text-blue-700',
|
||||
received: 'bg-green-100 text-green-700',
|
||||
};
|
||||
const labels: Record<string, string> = {
|
||||
pending: 'Pendiente',
|
||||
partial: 'Parcial',
|
||||
received: 'Recibido',
|
||||
};
|
||||
return (
|
||||
<span className={`inline-flex rounded-full px-2 py-1 text-xs font-medium ${colors[status] || 'bg-gray-100 text-gray-700'}`}>
|
||||
{labels[status] || status}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
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: '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);
|
||||
}
|
||||
};
|
||||
|
||||
// Calculate summary stats
|
||||
const draftCount = orders.filter(o => o.status === 'draft').length;
|
||||
const confirmedCount = orders.filter(o => o.status === 'confirmed').length;
|
||||
const totalAmount = orders.reduce((sum, o) => sum + o.amountTotal, 0);
|
||||
const pendingReceipt = orders.filter(o => o.receiptStatus === 'pending' && o.status === 'confirmed').length;
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<ErrorEmptyState onRetry={refresh} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 p-6">
|
||||
<Breadcrumbs items={[
|
||||
{ label: 'Compras', href: '/purchases' },
|
||||
{ label: 'Ordenes de Compra' },
|
||||
]} />
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Ordenes de Compra</h1>
|
||||
<p className="text-sm text-gray-500">
|
||||
Gestiona ordenes de compra y recepciones de proveedores
|
||||
</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 orden
|
||||
</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">
|
||||
<ShoppingBag 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('confirmed')}>
|
||||
<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">Confirmadas</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">
|
||||
<Package className="h-5 w-5 text-amber-600" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-gray-500">Por Recibir</div>
|
||||
<div className="text-xl font-bold text-amber-600">{pendingReceipt}</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 Compras</div>
|
||||
<div className="text-xl font-bold text-blue-600">${formatCurrency(totalAmount)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Lista de Ordenes</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 ordenes..."
|
||||
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 PurchaseOrderStatus | '')}
|
||||
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="ordenes de compra"
|
||||
/>
|
||||
) : (
|
||||
<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 orden de compra"
|
||||
message={`¿Confirmar la orden ${orderToConfirm?.name}? Esta accion enviara la orden al proveedor.`}
|
||||
variant="success"
|
||||
confirmText="Confirmar"
|
||||
/>
|
||||
|
||||
{/* Cancel Order Modal */}
|
||||
<ConfirmModal
|
||||
isOpen={!!orderToCancel}
|
||||
onClose={() => setOrderToCancel(null)}
|
||||
onConfirm={handleCancel}
|
||||
title="Cancelar orden de compra"
|
||||
message={`¿Cancelar la orden ${orderToCancel?.name}? Esta accion no se puede deshacer.`}
|
||||
variant="danger"
|
||||
confirmText="Cancelar orden"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default PurchaseOrdersPage;
|
||||
399
src/pages/purchases/PurchaseReceiptsPage.tsx
Normal file
399
src/pages/purchases/PurchaseReceiptsPage.tsx
Normal file
@ -0,0 +1,399 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Package,
|
||||
Plus,
|
||||
MoreVertical,
|
||||
Eye,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
Calendar,
|
||||
RefreshCw,
|
||||
Search,
|
||||
Truck,
|
||||
ClipboardList,
|
||||
} 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 { usePurchaseReceipts } from '@features/purchases/hooks';
|
||||
import type { PurchaseReceipt } from '@features/purchases/types';
|
||||
import { formatDate } from '@utils/formatters';
|
||||
|
||||
type ReceiptStatus = 'draft' | 'done' | 'cancelled';
|
||||
|
||||
const statusLabels: Record<ReceiptStatus, string> = {
|
||||
draft: 'Borrador',
|
||||
done: 'Completado',
|
||||
cancelled: 'Cancelado',
|
||||
};
|
||||
|
||||
const statusColors: Record<ReceiptStatus, string> = {
|
||||
draft: 'bg-gray-100 text-gray-700',
|
||||
done: 'bg-green-100 text-green-700',
|
||||
cancelled: 'bg-red-100 text-red-700',
|
||||
};
|
||||
|
||||
// Helper function to get current date
|
||||
const getToday = (): string => {
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
return today || '';
|
||||
};
|
||||
|
||||
export function PurchaseReceiptsPage() {
|
||||
const [selectedStatus, setSelectedStatus] = useState<ReceiptStatus | ''>('');
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [dateFrom, setDateFrom] = useState('');
|
||||
const [dateTo, setDateTo] = useState('');
|
||||
const [receiptToConfirm, setReceiptToConfirm] = useState<PurchaseReceipt | null>(null);
|
||||
const [receiptToCancel, setReceiptToCancel] = useState<PurchaseReceipt | null>(null);
|
||||
|
||||
const {
|
||||
receipts,
|
||||
total,
|
||||
page,
|
||||
totalPages,
|
||||
isLoading,
|
||||
error,
|
||||
setPage,
|
||||
refresh,
|
||||
confirmReceipt,
|
||||
cancelReceipt,
|
||||
} = usePurchaseReceipts({
|
||||
status: selectedStatus || undefined,
|
||||
search: searchTerm || undefined,
|
||||
dateFrom: dateFrom || undefined,
|
||||
dateTo: dateTo || undefined,
|
||||
limit: 20,
|
||||
});
|
||||
|
||||
const getActionsMenu = (receipt: PurchaseReceipt): DropdownItem[] => {
|
||||
const items: DropdownItem[] = [
|
||||
{
|
||||
key: 'view',
|
||||
label: 'Ver detalle',
|
||||
icon: <Eye className="h-4 w-4" />,
|
||||
onClick: () => console.log('View', receipt.id),
|
||||
},
|
||||
];
|
||||
|
||||
if (receipt.status === 'draft') {
|
||||
items.push({
|
||||
key: 'confirm',
|
||||
label: 'Validar recepcion',
|
||||
icon: <CheckCircle className="h-4 w-4" />,
|
||||
onClick: () => setReceiptToConfirm(receipt),
|
||||
});
|
||||
items.push({
|
||||
key: 'cancel',
|
||||
label: 'Cancelar',
|
||||
icon: <XCircle className="h-4 w-4" />,
|
||||
danger: true,
|
||||
onClick: () => setReceiptToCancel(receipt),
|
||||
});
|
||||
}
|
||||
|
||||
return items;
|
||||
};
|
||||
|
||||
const columns: Column<PurchaseReceipt>[] = [
|
||||
{
|
||||
key: 'receiptNumber',
|
||||
header: 'Recepcion',
|
||||
render: (receipt) => (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-green-50">
|
||||
<Package className="h-5 w-5 text-green-600" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium text-gray-900">{receipt.receiptNumber}</div>
|
||||
<div className="text-sm text-gray-500">OC: {receipt.purchaseOrderName || receipt.purchaseOrderId}</div>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'partner',
|
||||
header: 'Proveedor',
|
||||
render: (receipt) => (
|
||||
<div>
|
||||
<div className="text-sm text-gray-900">{receipt.partnerName || receipt.partnerId}</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'date',
|
||||
header: 'Fecha Recepcion',
|
||||
sortable: true,
|
||||
render: (receipt) => (
|
||||
<span className="text-sm text-gray-600">
|
||||
{formatDate(receipt.receiptDate, 'short')}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'items',
|
||||
header: 'Productos',
|
||||
render: (receipt) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<ClipboardList className="h-4 w-4 text-gray-400" />
|
||||
<span className="text-sm text-gray-600">
|
||||
{receipt.lines?.length || 0} lineas
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
header: 'Estado',
|
||||
render: (receipt) => (
|
||||
<span className={`inline-flex rounded-full px-2 py-1 text-xs font-medium ${statusColors[receipt.status]}`}>
|
||||
{statusLabels[receipt.status]}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
header: '',
|
||||
render: (receipt) => (
|
||||
<Dropdown
|
||||
trigger={
|
||||
<button className="rounded p-1 hover:bg-gray-100">
|
||||
<MoreVertical className="h-4 w-4 text-gray-500" />
|
||||
</button>
|
||||
}
|
||||
items={getActionsMenu(receipt)}
|
||||
align="right"
|
||||
/>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const handleConfirm = async () => {
|
||||
if (receiptToConfirm) {
|
||||
await confirmReceipt(receiptToConfirm.id);
|
||||
setReceiptToConfirm(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = async () => {
|
||||
if (receiptToCancel) {
|
||||
await cancelReceipt(receiptToCancel.id);
|
||||
setReceiptToCancel(null);
|
||||
}
|
||||
};
|
||||
|
||||
// Calculate summary stats
|
||||
const draftCount = receipts.filter(r => r.status === 'draft').length;
|
||||
const doneCount = receipts.filter(r => r.status === 'done').length;
|
||||
const todayReceipts = receipts.filter(r => r.receiptDate === getToday()).length;
|
||||
const totalItems = receipts.reduce((sum, r) => sum + (r.lines?.length || 0), 0);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<ErrorEmptyState onRetry={refresh} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 p-6">
|
||||
<Breadcrumbs items={[
|
||||
{ label: 'Compras', href: '/purchases' },
|
||||
{ label: 'Recepciones' },
|
||||
]} />
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Recepciones de Compra</h1>
|
||||
<p className="text-sm text-gray-500">
|
||||
Registra y valida la recepcion de productos de proveedores
|
||||
</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 recepcion
|
||||
</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">
|
||||
<Package className="h-5 w-5 text-gray-600" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-gray-500">Pendientes</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('done')}>
|
||||
<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">Completadas</div>
|
||||
<div className="text-xl font-bold text-green-600">{doneCount}</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">
|
||||
<Truck className="h-5 w-5 text-blue-600" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-gray-500">Hoy</div>
|
||||
<div className="text-xl font-bold text-blue-600">{todayReceipts}</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">
|
||||
<ClipboardList className="h-5 w-5 text-purple-600" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-gray-500">Items Recibidos</div>
|
||||
<div className="text-xl font-bold text-purple-600">{totalItems}</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Lista de Recepciones</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 recepciones..."
|
||||
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 ReceiptStatus | '')}
|
||||
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 */}
|
||||
{receipts.length === 0 && !isLoading ? (
|
||||
<NoDataEmptyState
|
||||
entityName="recepciones de compra"
|
||||
/>
|
||||
) : (
|
||||
<DataTable
|
||||
data={receipts}
|
||||
columns={columns}
|
||||
isLoading={isLoading}
|
||||
pagination={{
|
||||
page,
|
||||
totalPages,
|
||||
total,
|
||||
limit: 20,
|
||||
onPageChange: setPage,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Confirm Receipt Modal */}
|
||||
<ConfirmModal
|
||||
isOpen={!!receiptToConfirm}
|
||||
onClose={() => setReceiptToConfirm(null)}
|
||||
onConfirm={handleConfirm}
|
||||
title="Validar recepcion"
|
||||
message={`¿Validar la recepcion ${receiptToConfirm?.receiptNumber}? Esto actualizara el inventario.`}
|
||||
variant="success"
|
||||
confirmText="Validar"
|
||||
/>
|
||||
|
||||
{/* Cancel Receipt Modal */}
|
||||
<ConfirmModal
|
||||
isOpen={!!receiptToCancel}
|
||||
onClose={() => setReceiptToCancel(null)}
|
||||
onConfirm={handleCancel}
|
||||
title="Cancelar recepcion"
|
||||
message={`¿Cancelar la recepcion ${receiptToCancel?.receiptNumber}? Esta accion no se puede deshacer.`}
|
||||
variant="danger"
|
||||
confirmText="Cancelar recepcion"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default PurchaseReceiptsPage;
|
||||
2
src/pages/purchases/index.ts
Normal file
2
src/pages/purchases/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export { PurchaseOrdersPage, default as PurchaseOrdersPageDefault } from './PurchaseOrdersPage';
|
||||
export { PurchaseReceiptsPage, default as PurchaseReceiptsPageDefault } from './PurchaseReceiptsPage';
|
||||
Loading…
Reference in New Issue
Block a user