607 lines
29 KiB
TypeScript
607 lines
29 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, useDebounce } 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, Label, Popover, PopoverTrigger, PopoverContent } from '@horux/shared-ui';
|
|
import { useAuthStore } from '@/stores/auth-store';
|
|
import { formatCurrency, toCfdiDate } 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, Search, ArrowUpDown, Filter } 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 };
|
|
}
|
|
|
|
function FilterHeader({
|
|
label,
|
|
filterKey,
|
|
filters,
|
|
setFilters,
|
|
openFilter,
|
|
setOpenFilter,
|
|
suggestions,
|
|
}: {
|
|
label: string;
|
|
filterKey: string;
|
|
filters: Record<string, string>;
|
|
setFilters: any;
|
|
openFilter: string | null;
|
|
setOpenFilter: (v: string | null) => void;
|
|
suggestions: string[];
|
|
}) {
|
|
const rawValue = (filters as any)[filterKey] || '';
|
|
const [localValue, setLocalValue] = useState(rawValue);
|
|
const debouncedValue = useDebounce(localValue, 300);
|
|
|
|
// Sync local state when popover opens or external filter changes
|
|
useEffect(() => {
|
|
setLocalValue(rawValue);
|
|
}, [rawValue, openFilter === filterKey]);
|
|
|
|
// Update parent filter only when debounced value changes
|
|
useEffect(() => {
|
|
if (debouncedValue !== rawValue) {
|
|
setFilters((prev: any) => ({ ...prev, [filterKey]: debouncedValue }));
|
|
}
|
|
}, [debouncedValue]);
|
|
|
|
const hasFilter = !!rawValue;
|
|
const filteredSuggestions = localValue.length >= 1
|
|
? suggestions.filter((s) => s.toLowerCase().includes(localValue.toLowerCase())).slice(0, 8)
|
|
: suggestions.slice(0, 8);
|
|
|
|
return (
|
|
<div className="flex items-center justify-center gap-1">
|
|
{label}
|
|
<Popover open={openFilter === filterKey} onOpenChange={(open) => setOpenFilter(open ? filterKey : null)}>
|
|
<PopoverTrigger asChild>
|
|
<button className={`p-1 rounded hover:bg-muted ${hasFilter ? 'text-primary' : ''}`}>
|
|
<Filter className="h-3.5 w-3.5" />
|
|
</button>
|
|
</PopoverTrigger>
|
|
<PopoverContent className="w-72" align="start">
|
|
<div className="space-y-3">
|
|
<h4 className="font-medium text-sm">Filtrar por {label}</h4>
|
|
<div className="relative">
|
|
<Label className="text-xs">Contiene</Label>
|
|
<Input
|
|
placeholder={`Buscar ${label.toLowerCase()}...`}
|
|
className="h-8 text-sm"
|
|
value={localValue}
|
|
onChange={(e) => setLocalValue(e.target.value)}
|
|
autoFocus
|
|
/>
|
|
{filteredSuggestions.length > 0 && (
|
|
<div className="absolute top-full left-0 right-0 mt-1 bg-background border rounded-md shadow-lg max-h-40 overflow-y-auto z-50">
|
|
{filteredSuggestions.map((s, idx) => (
|
|
<button
|
|
key={idx}
|
|
className="w-full text-left px-3 py-1.5 text-sm hover:bg-muted truncate"
|
|
onClick={() => {
|
|
setLocalValue(s);
|
|
setFilters((prev: any) => ({ ...prev, [filterKey]: s }));
|
|
setOpenFilter(null);
|
|
}}
|
|
>
|
|
{s}
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div className="flex gap-2">
|
|
<Button size="sm" onClick={() => setOpenFilter(null)}>Aplicar</Button>
|
|
{hasFilter && (
|
|
<Button size="sm" variant="outline" onClick={() => { setFilters((prev: any) => ({ ...prev, [filterKey]: '' })); setOpenFilter(null); }}>
|
|
Limpiar
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</PopoverContent>
|
|
</Popover>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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);
|
|
|
|
// Ordenación — Por conciliar
|
|
const [sortPendientes, setSortPendientes] = useState<{ field: 'fecha' | 'total'; dir: 'asc' | 'desc' } | null>(null);
|
|
|
|
// Ordenación — Conciliadas
|
|
const [sortConciliadas, setSortConciliadas] = useState<{ field: 'fecha' | 'total'; dir: 'asc' | 'desc' } | null>(null);
|
|
|
|
// Filtros por columna — Por conciliar
|
|
const [filtersPendientes, setFiltersPendientes] = useState({ rfcEmisor: '', nombreEmisor: '', rfcReceptor: '', nombreReceptor: '' });
|
|
const [openFilterPendientes, setOpenFilterPendientes] = useState<string | null>(null);
|
|
|
|
// Filtros por columna — Conciliadas
|
|
const [filtersConciliadas, setFiltersConciliadas] = useState({ rfcEmisor: '', nombreEmisor: '', rfcReceptor: '', nombreReceptor: '', banco: '' });
|
|
const [openFilterConciliadas, setOpenFilterConciliadas] = useState<string | null>(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') || [];
|
|
|
|
// Sugerencias únicas para filtros de columna (de todos los CFDIs cargados)
|
|
const allCfdis = cfdis || [];
|
|
const uniqueSuggestions = {
|
|
rfcEmisor: [...new Set(allCfdis.map((c: any) => c.rfcEmisor).filter(Boolean))].sort(),
|
|
nombreEmisor: [...new Set(allCfdis.map((c: any) => c.nombreEmisor).filter(Boolean))].sort(),
|
|
rfcReceptor: [...new Set(allCfdis.map((c: any) => c.rfcReceptor).filter(Boolean))].sort(),
|
|
nombreReceptor: [...new Set(allCfdis.map((c: any) => c.nombreReceptor).filter(Boolean))].sort(),
|
|
banco: [...new Set(conciliadas.map((c: any) => c.conciliacion?.banco).filter(Boolean))].sort(),
|
|
};
|
|
|
|
// Score cards — tipo P usa monto_pago_mxn, otros usan total_mxn
|
|
// I+P-E: Ingresos y Pagos suman, Egresos restan
|
|
const getMonto = (c: any) => {
|
|
const monto = Number(c.montoMxn || c.totalMxn || 0);
|
|
return c.tipoComprobante === 'E' ? -monto : monto;
|
|
};
|
|
const montoConciliado = conciliadas.reduce((s, c) => s + getMonto(c), 0);
|
|
const montoPendiente = pendientes.reduce((s, c) => s + getMonto(c), 0);
|
|
|
|
// Reset selection + ordenación + filtros on tab/filter change
|
|
useEffect(() => {
|
|
setSelected(new Set());
|
|
setSortPendientes(null);
|
|
setSortConciliadas(null);
|
|
setFiltersPendientes({ rfcEmisor: '', nombreEmisor: '', rfcReceptor: '', nombreReceptor: '' });
|
|
setOpenFilterPendientes(null);
|
|
setFiltersConciliadas({ rfcEmisor: '', nombreEmisor: '', rfcReceptor: '', nombreReceptor: '', banco: '' });
|
|
setOpenFilterConciliadas(null);
|
|
}, [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 === pendientesOrdenados.length && pendientesOrdenados.length > 0) {
|
|
setSelected(new Set());
|
|
} else {
|
|
setSelected(new Set(pendientesOrdenados.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');
|
|
}
|
|
};
|
|
|
|
function matchesColumnFilters(c: any, filters: Record<string, string>) {
|
|
const rfcEmisorMatch = !filters.rfcEmisor || (c.rfcEmisor || '').toLowerCase().includes(filters.rfcEmisor.toLowerCase());
|
|
const nombreEmisorMatch = !filters.nombreEmisor || (c.nombreEmisor || '').toLowerCase().includes(filters.nombreEmisor.toLowerCase());
|
|
const rfcReceptorMatch = !filters.rfcReceptor || (c.rfcReceptor || '').toLowerCase().includes(filters.rfcReceptor.toLowerCase());
|
|
const nombreReceptorMatch = !filters.nombreReceptor || (c.nombreReceptor || '').toLowerCase().includes(filters.nombreReceptor.toLowerCase());
|
|
const bancoMatch = !filters.banco || ((c.conciliacion?.banco || '') + ' ****' + (c.conciliacion?.terminacionCuenta || '')).toLowerCase().includes(filters.banco.toLowerCase());
|
|
return rfcEmisorMatch && nombreEmisorMatch && rfcReceptorMatch && nombreReceptorMatch && bancoMatch;
|
|
}
|
|
|
|
function sortCfdis(list: any[], sort: { field: 'fecha' | 'total'; dir: 'asc' | 'desc' } | null) {
|
|
if (!sort) return list;
|
|
const sorted = [...list].sort((a, b) => {
|
|
if (sort.field === 'fecha') {
|
|
const da = toCfdiDate(a.fechaPagoP || a.fechaEmision).getTime();
|
|
const db = toCfdiDate(b.fechaPagoP || b.fechaEmision).getTime();
|
|
return sort.dir === 'asc' ? da - db : db - da;
|
|
}
|
|
if (sort.field === 'total') {
|
|
const ta = getMonto(a);
|
|
const tb = getMonto(b);
|
|
return sort.dir === 'asc' ? ta - tb : tb - ta;
|
|
}
|
|
return 0;
|
|
});
|
|
return sorted;
|
|
}
|
|
|
|
const pendientesOrdenados = sortCfdis(
|
|
pendientes.filter((c) => matchesColumnFilters(c, filtersPendientes)),
|
|
sortPendientes
|
|
);
|
|
const conciliadasOrdenadas = sortCfdis(
|
|
conciliadas.filter((c) => matchesColumnFilters(c, filtersConciliadas)),
|
|
sortConciliadas
|
|
);
|
|
|
|
const handleExport = () => {
|
|
const allVisible = [...pendientesOrdenados, ...conciliadasOrdenadas];
|
|
if (!allVisible.length) return;
|
|
exportToExcel(
|
|
allVisible.map((c) => ({
|
|
...c,
|
|
_fecha: toCfdiDate(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 ({pendientesOrdenados.length})</h3>
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full text-sm">
|
|
<thead>
|
|
<tr className="border-b text-center text-muted-foreground">
|
|
{!isVisor && (
|
|
<th className="pb-3 w-8">
|
|
<input
|
|
type="checkbox"
|
|
checked={
|
|
selected.size === pendientesOrdenados.length && pendientesOrdenados.length > 0
|
|
}
|
|
onChange={toggleSelectAll}
|
|
/>
|
|
</th>
|
|
)}
|
|
<th className="pb-3 font-medium">UUID</th>
|
|
<th className="pb-3 font-medium cursor-pointer select-none" onClick={() => setSortPendientes(prev => prev?.field === 'fecha' ? { field: 'fecha', dir: prev.dir === 'asc' ? 'desc' : 'asc' } : { field: 'fecha', dir: 'asc' })}>
|
|
<span className="flex items-center justify-center gap-1">Fecha <ArrowUpDown className="h-3 w-3" /></span>
|
|
</th>
|
|
<th className="pb-3 font-medium"><FilterHeader label="RFC Emisor" filterKey="rfcEmisor" filters={filtersPendientes} setFilters={setFiltersPendientes} openFilter={openFilterPendientes} setOpenFilter={setOpenFilterPendientes} suggestions={uniqueSuggestions.rfcEmisor} /></th>
|
|
<th className="pb-3 font-medium"><FilterHeader label="Nombre Emisor" filterKey="nombreEmisor" filters={filtersPendientes} setFilters={setFiltersPendientes} openFilter={openFilterPendientes} setOpenFilter={setOpenFilterPendientes} suggestions={uniqueSuggestions.nombreEmisor} /></th>
|
|
<th className="pb-3 font-medium"><FilterHeader label="RFC Receptor" filterKey="rfcReceptor" filters={filtersPendientes} setFilters={setFiltersPendientes} openFilter={openFilterPendientes} setOpenFilter={setOpenFilterPendientes} suggestions={uniqueSuggestions.rfcReceptor} /></th>
|
|
<th className="pb-3 font-medium"><FilterHeader label="Nombre Receptor" filterKey="nombreReceptor" filters={filtersPendientes} setFilters={setFiltersPendientes} openFilter={openFilterPendientes} setOpenFilter={setOpenFilterPendientes} suggestions={uniqueSuggestions.nombreReceptor} /></th>
|
|
<th className="pb-3 font-medium cursor-pointer select-none" onClick={() => setSortPendientes(prev => prev?.field === 'total' ? { field: 'total', dir: prev.dir === 'asc' ? 'desc' : 'asc' } : { field: 'total', dir: 'asc' })}>
|
|
<span className="flex items-center justify-center gap-1">Total MXN <ArrowUpDown className="h-3 w-3" /></span>
|
|
</th>
|
|
<th className="pb-3 font-medium">M. Pago</th>
|
|
<th className="pb-3"></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{pendientesOrdenados.length === 0 ? (
|
|
<tr>
|
|
<td colSpan={isVisor ? 9 : 10} className="py-4 text-sm text-muted-foreground text-center">
|
|
No hay CFDIs pendientes de conciliar
|
|
</td>
|
|
</tr>
|
|
) : (
|
|
pendientesOrdenados.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 text-center" title={cfdi.uuid}>
|
|
{cfdi.uuid?.substring(0, 8)}
|
|
</td>
|
|
<td className="py-2 text-xs text-center">
|
|
{toCfdiDate(cfdi.fechaPagoP || cfdi.fechaEmision).toLocaleDateString('es-MX')}
|
|
</td>
|
|
<td className="py-2 font-mono text-xs text-center">{cfdi.rfcEmisor}</td>
|
|
<td className="py-2 text-xs truncate max-w-[120px] text-center">
|
|
{cfdi.nombreEmisor}
|
|
</td>
|
|
<td className="py-2 font-mono text-xs text-center">{cfdi.rfcReceptor}</td>
|
|
<td className="py-2 text-xs truncate max-w-[120px] text-center">
|
|
{cfdi.nombreReceptor}
|
|
</td>
|
|
<td className="py-2 text-xs font-medium text-center">
|
|
{formatCurrencyConciliacion(getMonto(cfdi))}
|
|
</td>
|
|
<td className="py-2 text-xs text-center">{cfdi.metodoPago || '-'}</td>
|
|
<td className="py-2 text-center">
|
|
<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 ({conciliadasOrdenadas.length})</h3>
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full text-sm">
|
|
<thead>
|
|
<tr className="border-b text-center text-muted-foreground">
|
|
<th className="pb-3 font-medium">UUID</th>
|
|
<th className="pb-3 font-medium cursor-pointer select-none" onClick={() => setSortConciliadas(prev => prev?.field === 'fecha' ? { field: 'fecha', dir: prev.dir === 'asc' ? 'desc' : 'asc' } : { field: 'fecha', dir: 'asc' })}>
|
|
<span className="flex items-center justify-center gap-1">Fecha Emisión <ArrowUpDown className="h-3 w-3" /></span>
|
|
</th>
|
|
<th className="pb-3 font-medium"><FilterHeader label={activeTab === 'EMITIDO' ? 'RFC Receptor' : 'RFC Emisor'} filterKey={activeTab === 'EMITIDO' ? 'rfcReceptor' : 'rfcEmisor'} filters={filtersConciliadas} setFilters={setFiltersConciliadas} openFilter={openFilterConciliadas} setOpenFilter={setOpenFilterConciliadas} suggestions={activeTab === 'EMITIDO' ? uniqueSuggestions.rfcReceptor : uniqueSuggestions.rfcEmisor} /></th>
|
|
<th className="pb-3 font-medium"><FilterHeader label={activeTab === 'EMITIDO' ? 'Nombre Receptor' : 'Nombre Emisor'} filterKey={activeTab === 'EMITIDO' ? 'nombreReceptor' : 'nombreEmisor'} filters={filtersConciliadas} setFilters={setFiltersConciliadas} openFilter={openFilterConciliadas} setOpenFilter={setOpenFilterConciliadas} suggestions={activeTab === 'EMITIDO' ? uniqueSuggestions.nombreReceptor : uniqueSuggestions.nombreEmisor} /></th>
|
|
<th className="pb-3 font-medium cursor-pointer select-none" onClick={() => setSortConciliadas(prev => prev?.field === 'total' ? { field: 'total', dir: prev.dir === 'asc' ? 'desc' : 'asc' } : { field: 'total', dir: 'asc' })}>
|
|
<span className="flex items-center justify-center gap-1">Total MXN <ArrowUpDown className="h-3 w-3" /></span>
|
|
</th>
|
|
<th className="pb-3 font-medium">Fecha Pago</th>
|
|
<th className="pb-3 font-medium"><FilterHeader label="Banco" filterKey="banco" filters={filtersConciliadas} setFilters={setFiltersConciliadas} openFilter={openFilterConciliadas} setOpenFilter={setOpenFilterConciliadas} suggestions={uniqueSuggestions.banco} /></th>
|
|
<th className="pb-3"></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{conciliadasOrdenadas.length === 0 ? (
|
|
<tr>
|
|
<td colSpan={8} className="py-4 text-sm text-muted-foreground text-center">
|
|
No hay CFDIs conciliados
|
|
</td>
|
|
</tr>
|
|
) : (
|
|
conciliadasOrdenadas.map((cfdi) => (
|
|
<tr key={cfdi.id} className="border-b hover:bg-muted/50">
|
|
<td className="py-2 font-mono text-xs text-center" title={cfdi.uuid}>
|
|
{cfdi.uuid?.substring(0, 8)}
|
|
</td>
|
|
<td className="py-2 text-xs text-center">
|
|
{toCfdiDate(cfdi.fechaPagoP || cfdi.fechaEmision).toLocaleDateString('es-MX')}
|
|
</td>
|
|
<td className="py-2 font-mono text-xs text-center">{activeTab === 'EMITIDO' ? cfdi.rfcReceptor : cfdi.rfcEmisor}</td>
|
|
<td className="py-2 text-xs truncate max-w-[120px] text-center">
|
|
{activeTab === 'EMITIDO' ? cfdi.nombreReceptor : cfdi.nombreEmisor}
|
|
</td>
|
|
<td className="py-2 text-xs font-medium text-center">
|
|
{formatCurrencyConciliacion(getMonto(cfdi))}
|
|
</td>
|
|
<td className="py-2 text-xs text-center">
|
|
{cfdi.conciliacion?.fechaDePago
|
|
? new Date(
|
|
(cfdi.conciliacion.fechaDePago.split('T')[0]) + 'T12:00:00',
|
|
).toLocaleDateString('es-MX')
|
|
: '-'}
|
|
</td>
|
|
<td className="py-2 text-xs text-center">
|
|
{cfdi.conciliacion
|
|
? `${cfdi.conciliacion.banco} ****${cfdi.conciliacion.terminacionCuenta}`
|
|
: '-'}
|
|
</td>
|
|
<td className="py-2 flex gap-1 justify-center">
|
|
<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)}
|
|
/>
|
|
</>
|
|
);
|
|
}
|