Files
HoruxDespachosNuevo/apps/web/app/(dashboard)/conciliacion/page.tsx
Horux Dev b3b2838b6d fix(conciliacion): muestra nombre del banco en select en lugar de ID
El SelectValue del proyecto solo muestra el value raw (el ID).
Reemplazado por un span dentro de SelectTrigger que busca
el banco seleccionado por su ID y muestra el nombre + terminacion.
2026-05-13 22:38:50 +00:00

430 lines
18 KiB
TypeScript

'use client';
import { useState, useEffect } from 'react';
import { useCfdisConConciliacion, useConciliar, useDesconciliar } from '@/lib/hooks/use-conciliacion';
import { useBancos } from '@/lib/hooks/use-bancos';
import { useRegimenesDelPeriodo } from '@/lib/hooks/use-dashboard';
import { PeriodSelector, RegimenSelector } from '@horux/shared-ui';
import { CfdiViewerModal } from '@/components/cfdi/cfdi-viewer-modal';
import { Header } from '@/components/layouts/header';
import { Card, CardContent, Button, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Input } from '@horux/shared-ui';
import { useAuthStore } from '@/stores/auth-store';
import { formatCurrency } from '@/lib/utils';
function formatCurrencyConciliacion(value: number): string {
return new Intl.NumberFormat('es-MX', {
style: 'currency',
currency: 'MXN',
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(value);
}
import { exportToExcel } from '@/lib/export-excel';
import { Eye, Download, X, CheckCircle } from 'lucide-react';
function getMonthRange(year: number, month: number) {
const start = `${year}-${String(month).padStart(2, '0')}-01`;
const lastDay = new Date(year, month, 0).getDate();
const end = `${year}-${String(month).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}`;
return { start, end };
}
export default function ConciliacionPage() {
const now = new Date();
const defaultRange = getMonthRange(now.getFullYear(), now.getMonth() + 1);
const [fechaInicio, setFechaInicio] = useState(defaultRange.start);
const [fechaFin, setFechaFin] = useState(defaultRange.end);
const [regimenSeleccionado, setRegimenSeleccionado] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState<'EMITIDO' | 'RECIBIDO'>('EMITIDO');
const [selected, setSelected] = useState<Set<number>>(new Set());
const [fechaPago, setFechaPago] = useState('');
const [bancoId, setBancoId] = useState<string>('');
const [selectedCfdi, setSelectedCfdi] = useState<any>(null);
const { user } = useAuthStore();
const isVisor = user?.role === 'visor';
// Data
const { data: regimenes, isLoading: regimenesLoading } = useRegimenesDelPeriodo(fechaInicio, fechaFin);
const { data: cfdis, isLoading } = useCfdisConConciliacion({
tipo: activeTab,
fechaInicio,
fechaFin,
...(regimenSeleccionado && { regimen: regimenSeleccionado }),
});
const { data: bancos } = useBancos();
const conciliarMut = useConciliar();
const desconciliarMut = useDesconciliar();
// Split data
const pendientes = cfdis?.filter((c) => c.conciliado !== 'true') || [];
const conciliadas = cfdis?.filter((c) => c.conciliado === 'true') || [];
// Score cards — tipo P usa monto_pago_mxn, otros usan total_mxn
const getMonto = (c: any) => Number(c.montoMxn || c.totalMxn || 0);
const montoConciliado = conciliadas.reduce((s, c) => s + getMonto(c), 0);
const montoPendiente = pendientes.reduce((s, c) => s + getMonto(c), 0);
// Reset selection on tab/filter change
useEffect(() => {
setSelected(new Set());
}, [activeTab, fechaInicio, fechaFin, regimenSeleccionado]);
// Handlers
const toggleSelect = (id: number) => {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
return next;
});
};
const toggleSelectAll = () => {
if (selected.size === pendientes.length && pendientes.length > 0) {
setSelected(new Set());
} else {
setSelected(new Set(pendientes.map((c) => c.id)));
}
};
const handleConciliar = async () => {
if (selected.size === 0 || !fechaPago || !bancoId) return;
try {
await conciliarMut.mutateAsync({
cfdiIds: Array.from(selected),
fechaDePago: fechaPago,
idBanco: parseInt(bancoId),
});
setSelected(new Set());
setFechaPago('');
setBancoId('');
} catch (err: any) {
alert(err.response?.data?.message || 'Error al conciliar');
}
};
const handleDesconciliar = async (conciliacionId: number) => {
if (!confirm('¿Desconciliar este CFDI?')) return;
try {
await desconciliarMut.mutateAsync(conciliacionId);
} catch (err: any) {
alert(err.response?.data?.message || 'Error al desconciliar');
}
};
const handleExport = () => {
if (!cfdis?.length) return;
exportToExcel(
cfdis.map((c) => ({
...c,
_fecha: new Date(c.fechaPagoP || c.fechaEmision).toLocaleDateString('es-MX'),
_totalMxn: getMonto(c),
_estado: c.conciliado === 'true' ? 'Conciliado' : 'Pendiente',
_fechaPago: c.conciliacion?.fechaDePago || '',
_banco: c.conciliacion
? `${c.conciliacion.banco} ****${c.conciliacion.terminacionCuenta}`
: '',
})),
[
{ header: 'UUID', key: 'uuid', width: 40 },
{ header: 'Fecha Emisión', key: '_fecha', width: 15 },
{ header: 'RFC Emisor', key: 'rfcEmisor', width: 15 },
{ header: 'Nombre Emisor', key: 'nombreEmisor', width: 30 },
{ header: 'RFC Receptor', key: 'rfcReceptor', width: 15 },
{ header: 'Total MXN', key: '_totalMxn', width: 15 },
{ header: 'Estado', key: '_estado', width: 12 },
{ header: 'Fecha Pago', key: '_fechaPago', width: 15 },
{ header: 'Banco', key: '_banco', width: 20 },
],
`conciliacion-${activeTab.toLowerCase()}`,
);
};
return (
<>
<Header title="Conciliación">
<PeriodSelector
fechaInicio={fechaInicio}
fechaFin={fechaFin}
onChange={(i, f) => {
setFechaInicio(i);
setFechaFin(f);
}}
/>
</Header>
<main className="p-6 space-y-6">
{/* Regimen selector + Export button */}
<div className="flex items-center justify-between">
<RegimenSelector
regimenes={regimenes || []}
selected={regimenSeleccionado}
onChange={setRegimenSeleccionado}
isLoading={regimenesLoading}
/>
<Button variant="outline" size="sm" onClick={handleExport} disabled={!cfdis?.length}>
<Download className="h-4 w-4 mr-1" /> Excel
</Button>
</div>
{/* Score cards */}
<div className="grid gap-4 md:grid-cols-2">
<Card>
<CardContent className="p-4">
<p className="text-sm text-muted-foreground">Monto Conciliado</p>
<p className="text-2xl font-bold text-success">{formatCurrencyConciliacion(montoConciliado)}</p>
<p className="text-xs text-muted-foreground mt-1">{conciliadas.length} CFDIs</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<p className="text-sm text-muted-foreground">Monto Pendiente de Conciliar</p>
<p className="text-2xl font-bold text-destructive">{formatCurrencyConciliacion(montoPendiente)}</p>
<p className="text-xs text-muted-foreground mt-1">{pendientes.length} CFDIs</p>
</CardContent>
</Card>
</div>
{/* Tabs */}
<div className="flex gap-2">
{(['EMITIDO', 'RECIBIDO'] as const).map((tab) => (
<Button
key={tab}
variant={activeTab === tab ? 'default' : 'outline'}
onClick={() => {
setActiveTab(tab);
setSelected(new Set());
}}
>
{tab === 'EMITIDO' ? 'Emitidas' : 'Recibidas'}
</Button>
))}
</div>
{isLoading ? (
<div className="text-sm text-muted-foreground">Cargando...</div>
) : (
<>
{/* Por conciliar */}
<Card>
<CardContent className="pt-6">
<h3 className="font-medium mb-4">Por conciliar ({pendientes.length})</h3>
{pendientes.length === 0 ? (
<p className="text-sm text-muted-foreground">
No hay CFDIs pendientes de conciliar
</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground">
{!isVisor && (
<th className="pb-3 w-8">
<input
type="checkbox"
checked={
selected.size === pendientes.length && pendientes.length > 0
}
onChange={toggleSelectAll}
/>
</th>
)}
<th className="pb-3 font-medium">UUID</th>
<th className="pb-3 font-medium">Fecha</th>
<th className="pb-3 font-medium">RFC Emisor</th>
<th className="pb-3 font-medium">Nombre Emisor</th>
<th className="pb-3 font-medium">RFC Receptor</th>
<th className="pb-3 font-medium">Nombre Receptor</th>
<th className="pb-3 font-medium text-right">Total MXN</th>
<th className="pb-3 font-medium">M. Pago</th>
<th className="pb-3"></th>
</tr>
</thead>
<tbody>
{pendientes.map((cfdi) => (
<tr key={cfdi.id} className="border-b hover:bg-muted/50">
{!isVisor && (
<td className="py-2">
<input
type="checkbox"
checked={selected.has(cfdi.id)}
onChange={() => toggleSelect(cfdi.id)}
/>
</td>
)}
<td className="py-2 font-mono text-xs" title={cfdi.uuid}>
{cfdi.uuid?.substring(0, 8)}
</td>
<td className="py-2 text-xs">
{new Date(cfdi.fechaPagoP || cfdi.fechaEmision).toLocaleDateString('es-MX')}
</td>
<td className="py-2 font-mono text-xs">{cfdi.rfcEmisor}</td>
<td className="py-2 text-xs truncate max-w-[120px]">
{cfdi.nombreEmisor}
</td>
<td className="py-2 font-mono text-xs">{cfdi.rfcReceptor}</td>
<td className="py-2 text-xs truncate max-w-[120px]">
{cfdi.nombreReceptor}
</td>
<td className="py-2 text-right text-xs font-medium">
{formatCurrencyConciliacion(getMonto(cfdi))}
</td>
<td className="py-2 text-xs">{cfdi.metodoPago || '-'}</td>
<td className="py-2">
<Button
variant="ghost"
size="sm"
onClick={() => setSelectedCfdi(cfdi)}
title="Ver factura"
>
<Eye className="h-4 w-4" />
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
{/* Action bar - only when items selected */}
{!isVisor && selected.size > 0 && (
<div className="sticky bottom-4 z-10 bg-card border rounded-lg shadow-lg p-4 flex items-center gap-4">
<span className="text-sm font-medium">{selected.size} seleccionados</span>
{(() => {
const selectedBank = bancos?.find((x) => String(x.id) === bancoId);
return (
<Select value={bancoId} onValueChange={setBancoId}>
<SelectTrigger className="w-48">
<span className="truncate">
{selectedBank
? `${selectedBank.banco} ****${selectedBank.terminacionCuenta}`
: 'Seleccionar banco'}
</span>
</SelectTrigger>
<SelectContent>
{bancos?.map((b) => (
<SelectItem key={b.id} value={String(b.id)}>
{b.banco} ****{b.terminacionCuenta}
</SelectItem>
))}
</SelectContent>
</Select>
);
})()}
<Input
type="date"
value={fechaPago}
onChange={(e) => setFechaPago(e.target.value)}
className="w-44"
/>
<Button
onClick={handleConciliar}
disabled={!fechaPago || !bancoId || conciliarMut.isPending}
>
<CheckCircle className="h-4 w-4 mr-1" />
Conciliar {selected.size} facturas
</Button>
<Button variant="ghost" size="sm" onClick={() => setSelected(new Set())}>
<X className="h-4 w-4" />
</Button>
</div>
)}
{/* Conciliadas */}
<Card>
<CardContent className="pt-6">
<h3 className="font-medium mb-4">Conciliadas ({conciliadas.length})</h3>
{conciliadas.length === 0 ? (
<p className="text-sm text-muted-foreground">No hay CFDIs conciliados</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground">
<th className="pb-3 font-medium">UUID</th>
<th className="pb-3 font-medium">Fecha Emisión</th>
<th className="pb-3 font-medium">RFC Emisor</th>
<th className="pb-3 font-medium">Nombre Emisor</th>
<th className="pb-3 font-medium text-right">Total MXN</th>
<th className="pb-3 font-medium">Fecha Pago</th>
<th className="pb-3 font-medium">Banco</th>
<th className="pb-3"></th>
</tr>
</thead>
<tbody>
{conciliadas.map((cfdi) => (
<tr key={cfdi.id} className="border-b hover:bg-muted/50">
<td className="py-2 font-mono text-xs" title={cfdi.uuid}>
{cfdi.uuid?.substring(0, 8)}
</td>
<td className="py-2 text-xs">
{new Date(cfdi.fechaPagoP || cfdi.fechaEmision).toLocaleDateString('es-MX')}
</td>
<td className="py-2 font-mono text-xs">{cfdi.rfcEmisor}</td>
<td className="py-2 text-xs truncate max-w-[120px]">
{cfdi.nombreEmisor}
</td>
<td className="py-2 text-right text-xs font-medium">
{formatCurrencyConciliacion(getMonto(cfdi))}
</td>
<td className="py-2 text-xs">
{cfdi.conciliacion?.fechaDePago
? new Date(
cfdi.conciliacion.fechaDePago + 'T12:00:00',
).toLocaleDateString('es-MX')
: '-'}
</td>
<td className="py-2 text-xs">
{cfdi.conciliacion
? `${cfdi.conciliacion.banco} ****${cfdi.conciliacion.terminacionCuenta}`
: '-'}
</td>
<td className="py-2 flex gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => setSelectedCfdi(cfdi)}
title="Ver factura"
>
<Eye className="h-4 w-4" />
</Button>
{!isVisor && cfdi.conciliacion && (
<Button
variant="ghost"
size="sm"
onClick={() => handleDesconciliar(cfdi.conciliacion!.id)}
title="Desconciliar"
className="text-destructive hover:text-destructive"
>
<X className="h-4 w-4" />
</Button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
</>
)}
</main>
<CfdiViewerModal
cfdi={selectedCfdi}
open={!!selectedCfdi}
onClose={() => setSelectedCfdi(null)}
/>
</>
);
}