erp-core-frontend-v2/src/pages/financial/InvoicesPage.tsx
rckrdmrd 3a461cb184 [TASK-2026-01-20-005] feat: Resolve P2 gaps - Actions, Settings, Kanban
EPIC-P2-001: Frontend Actions Implementation
- Replace 31 console.log placeholders with navigate() calls
- 16 pages updated with proper routing

EPIC-P2-002: Settings Subpages Creation
- Add CompanySettingsPage.tsx
- Add ProfileSettingsPage.tsx
- Add SecuritySettingsPage.tsx
- Add SystemSettingsPage.tsx
- Update router with new routes

EPIC-P2-003: Bug Fix ValuationReportsPage
- Fix recursive getToday() function

EPIC-P2-006: CRM Pipeline Kanban
- Add PipelineKanbanPage.tsx
- Add KanbanColumn.tsx component
- Add KanbanCard.tsx component
- Add CRM routes to router

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 04:32:20 -06:00

472 lines
16 KiB
TypeScript

import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Receipt,
Plus,
MoreVertical,
Eye,
CheckCircle,
XCircle,
Calendar,
DollarSign,
RefreshCw,
Search,
Send,
CreditCard,
} 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 { useInvoices } from '@features/financial/hooks';
import type { FinancialInvoice, FinancialInvoiceStatus, InvoiceType } from '@features/financial/types';
import { formatDate, formatNumber } from '@utils/formatters';
const statusLabels: Record<FinancialInvoiceStatus, string> = {
draft: 'Borrador',
open: 'Abierta',
paid: 'Pagada',
cancelled: 'Cancelada',
};
const statusColors: Record<FinancialInvoiceStatus, string> = {
draft: 'bg-gray-100 text-gray-700',
open: 'bg-blue-100 text-blue-700',
paid: 'bg-green-100 text-green-700',
cancelled: 'bg-red-100 text-red-700',
};
const invoiceTypeLabels: Record<InvoiceType, string> = {
customer: 'Cliente',
supplier: 'Proveedor',
};
// Helper function to format currency with 2 decimals
const formatCurrency = (value: number): string => {
return formatNumber(value, 'es-MX', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
};
export function InvoicesPage() {
const navigate = useNavigate();
const [selectedStatus, setSelectedStatus] = useState<FinancialInvoiceStatus | ''>('');
const [selectedType, setSelectedType] = useState<InvoiceType | ''>('');
const [searchTerm, setSearchTerm] = useState('');
const [dateFrom, setDateFrom] = useState('');
const [dateTo, setDateTo] = useState('');
const [invoiceToValidate, setInvoiceToValidate] = useState<FinancialInvoice | null>(null);
const [invoiceToCancel, setInvoiceToCancel] = useState<FinancialInvoice | null>(null);
const {
invoices,
total,
page,
totalPages,
isLoading,
error,
setPage,
refresh,
validateInvoice,
cancelInvoice,
} = useInvoices({
status: selectedStatus || undefined,
invoiceType: selectedType || undefined,
search: searchTerm || undefined,
dateFrom: dateFrom || undefined,
dateTo: dateTo || undefined,
limit: 20,
});
const getActionsMenu = (invoice: FinancialInvoice): DropdownItem[] => {
const items: DropdownItem[] = [
{
key: 'view',
label: 'Ver detalle',
icon: <Eye className="h-4 w-4" />,
onClick: () => navigate(`/financial/invoices/${invoice.id}`),
},
];
if (invoice.status === 'draft') {
items.push({
key: 'validate',
label: 'Validar factura',
icon: <Send className="h-4 w-4" />,
onClick: () => setInvoiceToValidate(invoice),
});
items.push({
key: 'cancel',
label: 'Cancelar',
icon: <XCircle className="h-4 w-4" />,
danger: true,
onClick: () => setInvoiceToCancel(invoice),
});
}
if (invoice.status === 'open') {
items.push({
key: 'payment',
label: 'Registrar pago',
icon: <CreditCard className="h-4 w-4" />,
// TODO: Implement payment modal - setShowPaymentModal(true)
onClick: () => navigate(`/financial/invoices/${invoice.id}/payment`),
});
items.push({
key: 'cancel',
label: 'Cancelar factura',
icon: <XCircle className="h-4 w-4" />,
danger: true,
onClick: () => setInvoiceToCancel(invoice),
});
}
return items;
};
const columns: Column<FinancialInvoice>[] = [
{
key: 'number',
header: 'Factura',
render: (invoice) => (
<div className="flex items-center gap-3">
<div className={`flex h-10 w-10 items-center justify-center rounded-lg ${invoice.invoiceType === 'customer' ? 'bg-blue-50' : 'bg-amber-50'}`}>
<Receipt className={`h-5 w-5 ${invoice.invoiceType === 'customer' ? 'text-blue-600' : 'text-amber-600'}`} />
</div>
<div>
<div className="font-medium text-gray-900">{invoice.number || 'Sin numero'}</div>
{invoice.ref && (
<div className="text-sm text-gray-500">Ref: {invoice.ref}</div>
)}
</div>
</div>
),
},
{
key: 'type',
header: 'Tipo',
render: (invoice) => (
<span className={`inline-flex rounded-full px-2 py-1 text-xs font-medium ${invoice.invoiceType === 'customer' ? 'bg-blue-100 text-blue-700' : 'bg-amber-100 text-amber-700'}`}>
{invoiceTypeLabels[invoice.invoiceType]}
</span>
),
},
{
key: 'partner',
header: 'Cliente/Proveedor',
render: (invoice) => (
<div>
<div className="text-sm text-gray-900">{invoice.partnerName || invoice.partnerId}</div>
</div>
),
},
{
key: 'date',
header: 'Fecha',
sortable: true,
render: (invoice) => (
<span className="text-sm text-gray-600">
{formatDate(invoice.invoiceDate, 'short')}
</span>
),
},
{
key: 'dueDate',
header: 'Vencimiento',
render: (invoice) => (
<span className="text-sm text-gray-600">
{invoice.dueDate ? formatDate(invoice.dueDate, 'short') : '-'}
</span>
),
},
{
key: 'amount',
header: 'Total',
sortable: true,
render: (invoice) => (
<div className="text-right">
<div className="font-medium text-gray-900">
${formatCurrency(invoice.amountTotal)}
</div>
{invoice.currencyCode && invoice.currencyCode !== 'MXN' && (
<div className="text-xs text-gray-500">{invoice.currencyCode}</div>
)}
</div>
),
},
{
key: 'residual',
header: 'Pendiente',
render: (invoice) => (
<div className="text-right">
<span className={`font-medium ${invoice.amountResidual > 0 ? 'text-amber-600' : 'text-green-600'}`}>
${formatCurrency(invoice.amountResidual)}
</span>
</div>
),
},
{
key: 'status',
header: 'Estado',
render: (invoice) => (
<span className={`inline-flex rounded-full px-2 py-1 text-xs font-medium ${statusColors[invoice.status]}`}>
{statusLabels[invoice.status]}
</span>
),
},
{
key: 'actions',
header: '',
render: (invoice) => (
<Dropdown
trigger={
<button className="rounded p-1 hover:bg-gray-100">
<MoreVertical className="h-4 w-4 text-gray-500" />
</button>
}
items={getActionsMenu(invoice)}
align="right"
/>
),
},
];
const handleValidate = async () => {
if (invoiceToValidate) {
await validateInvoice(invoiceToValidate.id);
setInvoiceToValidate(null);
}
};
const handleCancel = async () => {
if (invoiceToCancel) {
await cancelInvoice(invoiceToCancel.id);
setInvoiceToCancel(null);
}
};
// Calculate summary stats
const draftCount = invoices.filter(i => i.status === 'draft').length;
const openCount = invoices.filter(i => i.status === 'open').length;
const totalAmount = invoices.reduce((sum, i) => sum + i.amountTotal, 0);
const pendingAmount = invoices.filter(i => i.status === 'open').reduce((sum, i) => sum + i.amountResidual, 0);
if (error) {
return (
<div className="p-6">
<ErrorEmptyState onRetry={refresh} />
</div>
);
}
return (
<div className="space-y-6 p-6">
<Breadcrumbs items={[
{ label: 'Contabilidad', href: '/financial' },
{ label: 'Facturas' },
]} />
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900">Facturas</h1>
<p className="text-sm text-gray-500">
Gestiona facturas de clientes y 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 factura
</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">
<Receipt 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('open')}>
<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">
<CheckCircle className="h-5 w-5 text-blue-600" />
</div>
<div>
<div className="text-sm text-gray-500">Abiertas</div>
<div className="text-xl font-bold text-blue-600">{openCount}</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">
<DollarSign className="h-5 w-5 text-amber-600" />
</div>
<div>
<div className="text-sm text-gray-500">Por Cobrar</div>
<div className="text-xl font-bold text-amber-600">${formatCurrency(pendingAmount)}</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-green-100">
<DollarSign className="h-5 w-5 text-green-600" />
</div>
<div>
<div className="text-sm text-gray-500">Total Facturado</div>
<div className="text-xl font-bold text-green-600">${formatCurrency(totalAmount)}</div>
</div>
</div>
</CardContent>
</Card>
</div>
<Card>
<CardHeader>
<CardTitle>Lista de Facturas</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 facturas..."
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={selectedType}
onChange={(e) => setSelectedType(e.target.value as InvoiceType | '')}
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 tipos</option>
{Object.entries(invoiceTypeLabels).map(([value, label]) => (
<option key={value} value={value}>{label}</option>
))}
</select>
<select
value={selectedStatus}
onChange={(e) => setSelectedStatus(e.target.value as FinancialInvoiceStatus | '')}
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 || selectedType || searchTerm || dateFrom || dateTo) && (
<Button
variant="ghost"
size="sm"
onClick={() => {
setSelectedStatus('');
setSelectedType('');
setSearchTerm('');
setDateFrom('');
setDateTo('');
}}
>
Limpiar filtros
</Button>
)}
</div>
{/* Table */}
{invoices.length === 0 && !isLoading ? (
<NoDataEmptyState
entityName="facturas"
/>
) : (
<DataTable
data={invoices}
columns={columns}
isLoading={isLoading}
pagination={{
page,
totalPages,
total,
limit: 20,
onPageChange: setPage,
}}
/>
)}
</div>
</CardContent>
</Card>
{/* Validate Invoice Modal */}
<ConfirmModal
isOpen={!!invoiceToValidate}
onClose={() => setInvoiceToValidate(null)}
onConfirm={handleValidate}
title="Validar factura"
message={`¿Validar la factura ${invoiceToValidate?.number || 'borrador'}? Total: $${invoiceToValidate ? formatCurrency(invoiceToValidate.amountTotal) : '0.00'}`}
variant="success"
confirmText="Validar"
/>
{/* Cancel Invoice Modal */}
<ConfirmModal
isOpen={!!invoiceToCancel}
onClose={() => setInvoiceToCancel(null)}
onConfirm={handleCancel}
title="Cancelar factura"
message={`¿Cancelar la factura ${invoiceToCancel?.number || 'borrador'}? Esta accion no se puede deshacer.`}
variant="danger"
confirmText="Cancelar factura"
/>
</div>
);
}
export default InvoicesPage;