Creada funcion local formatCurrencyConciliacion con minimumFractionDigits/maximumFractionDigits = 2. El resto del sitio mantiene formatCurrency original sin decimales.
421 lines
18 KiB
TypeScript
421 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>
|
|
<Select value={bancoId} onValueChange={setBancoId}>
|
|
<SelectTrigger className="w-48">
|
|
<SelectValue placeholder="Seleccionar banco" />
|
|
</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)}
|
|
/>
|
|
</>
|
|
);
|
|
}
|