Initial commit: Horux Despachos project

This commit is contained in:
consultoria-as
2026-04-27 01:11:06 -06:00
commit 56a05ba767
604 changed files with 121723 additions and 0 deletions

View 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)}
/>
</>
);
}