Initial commit - Horux Despachos NL
This commit is contained in:
411
apps/web/app/(dashboard)/conciliacion/page.tsx
Normal file
411
apps/web/app/(dashboard)/conciliacion/page.tsx
Normal file
@@ -0,0 +1,411 @@
|
||||
'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';
|
||||
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.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">{formatCurrency(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">{formatCurrency(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.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">
|
||||
{formatCurrency(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.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">
|
||||
{formatCurrency(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)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user