perf(cfdi): optimize page performance
Database optimizations: - Add indexes on fecha_emision, tipo, estado, rfc_emisor, rfc_receptor - Add trigram indexes for fast ILIKE searches on nombre fields - Combine COUNT with main query using window function (1 query instead of 2) Frontend optimizations: - Add 300ms debounce to autocomplete searches - Add staleTime (30s) and gcTime (5min) to useCfdis hook - Reduce unnecessary API calls on every keystroke Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -50,14 +50,9 @@ export async function getCfdis(schema: string, filters: CfdiFilters): Promise<Cf
|
||||
params.push(`%${filters.search}%`);
|
||||
}
|
||||
|
||||
const countResult = await prisma.$queryRawUnsafe<[{ count: number }]>(`
|
||||
SELECT COUNT(*) as count FROM "${schema}".cfdis ${whereClause}
|
||||
`, ...params);
|
||||
|
||||
const total = Number(countResult[0]?.count || 0);
|
||||
|
||||
// Combinar COUNT con la query principal usando window function
|
||||
params.push(limit, offset);
|
||||
const data = await prisma.$queryRawUnsafe<Cfdi[]>(`
|
||||
const dataWithCount = await prisma.$queryRawUnsafe<(Cfdi & { total_count: number })[]>(`
|
||||
SELECT
|
||||
id, uuid_fiscal as "uuidFiscal", tipo, serie, folio,
|
||||
fecha_emision as "fechaEmision", fecha_timbrado as "fechaTimbrado",
|
||||
@@ -68,13 +63,17 @@ export async function getCfdis(schema: string, filters: CfdiFilters): Promise<Cf
|
||||
tipo_cambio as "tipoCambio", metodo_pago as "metodoPago",
|
||||
forma_pago as "formaPago", uso_cfdi as "usoCfdi",
|
||||
estado, xml_url as "xmlUrl", pdf_url as "pdfUrl",
|
||||
created_at as "createdAt"
|
||||
created_at as "createdAt",
|
||||
COUNT(*) OVER() as total_count
|
||||
FROM "${schema}".cfdis
|
||||
${whereClause}
|
||||
ORDER BY fecha_emision DESC
|
||||
LIMIT $${paramIndex++} OFFSET $${paramIndex}
|
||||
`, ...params);
|
||||
|
||||
const total = Number(dataWithCount[0]?.total_count || 0);
|
||||
const data = dataWithCount.map(({ total_count, ...cfdi }) => cfdi) as Cfdi[];
|
||||
|
||||
return {
|
||||
data,
|
||||
total,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useRef, useCallback } from 'react';
|
||||
import { useState, useRef, useCallback, useEffect } from 'react';
|
||||
import { useDebounce } from '@/lib/hooks/use-debounce';
|
||||
import { Header } from '@/components/layouts/header';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -240,8 +241,39 @@ export default function CfdiPage() {
|
||||
const [openFilter, setOpenFilter] = useState<'fecha' | 'emisor' | 'receptor' | null>(null);
|
||||
const [emisorSuggestions, setEmisorSuggestions] = useState<EmisorReceptor[]>([]);
|
||||
const [receptorSuggestions, setReceptorSuggestions] = useState<EmisorReceptor[]>([]);
|
||||
const [loadingSuggestions, setLoadingSuggestions] = useState(false);
|
||||
const [loadingEmisor, setLoadingEmisor] = useState(false);
|
||||
const [loadingReceptor, setLoadingReceptor] = useState(false);
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
|
||||
// Debounced values for autocomplete
|
||||
const debouncedEmisor = useDebounce(columnFilters.emisor, 300);
|
||||
const debouncedReceptor = useDebounce(columnFilters.receptor, 300);
|
||||
|
||||
// Fetch emisor suggestions when debounced value changes
|
||||
useEffect(() => {
|
||||
if (debouncedEmisor.length < 2) {
|
||||
setEmisorSuggestions([]);
|
||||
return;
|
||||
}
|
||||
setLoadingEmisor(true);
|
||||
searchEmisores(debouncedEmisor)
|
||||
.then(setEmisorSuggestions)
|
||||
.catch(() => setEmisorSuggestions([]))
|
||||
.finally(() => setLoadingEmisor(false));
|
||||
}, [debouncedEmisor]);
|
||||
|
||||
// Fetch receptor suggestions when debounced value changes
|
||||
useEffect(() => {
|
||||
if (debouncedReceptor.length < 2) {
|
||||
setReceptorSuggestions([]);
|
||||
return;
|
||||
}
|
||||
setLoadingReceptor(true);
|
||||
searchReceptores(debouncedReceptor)
|
||||
.then(setReceptorSuggestions)
|
||||
.catch(() => setReceptorSuggestions([]))
|
||||
.finally(() => setLoadingReceptor(false));
|
||||
}, [debouncedReceptor]);
|
||||
const [showBulkForm, setShowBulkForm] = useState(false);
|
||||
const [formData, setFormData] = useState<CreateCfdiData>(initialFormData);
|
||||
const [bulkData, setBulkData] = useState('');
|
||||
@@ -291,42 +323,6 @@ export default function CfdiPage() {
|
||||
setFilters({ ...filters, search: searchTerm, page: 1 });
|
||||
};
|
||||
|
||||
// Debounced search for emisor suggestions
|
||||
const handleEmisorSearch = useCallback(async (value: string) => {
|
||||
setColumnFilters(prev => ({ ...prev, emisor: value }));
|
||||
if (value.length < 2) {
|
||||
setEmisorSuggestions([]);
|
||||
return;
|
||||
}
|
||||
setLoadingSuggestions(true);
|
||||
try {
|
||||
const results = await searchEmisores(value);
|
||||
setEmisorSuggestions(results);
|
||||
} catch {
|
||||
setEmisorSuggestions([]);
|
||||
} finally {
|
||||
setLoadingSuggestions(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Debounced search for receptor suggestions
|
||||
const handleReceptorSearch = useCallback(async (value: string) => {
|
||||
setColumnFilters(prev => ({ ...prev, receptor: value }));
|
||||
if (value.length < 2) {
|
||||
setReceptorSuggestions([]);
|
||||
return;
|
||||
}
|
||||
setLoadingSuggestions(true);
|
||||
try {
|
||||
const results = await searchReceptores(value);
|
||||
setReceptorSuggestions(results);
|
||||
} catch {
|
||||
setReceptorSuggestions([]);
|
||||
} finally {
|
||||
setLoadingSuggestions(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const selectEmisor = (emisor: EmisorReceptor) => {
|
||||
setColumnFilters(prev => ({ ...prev, emisor: emisor.nombre }));
|
||||
setEmisorSuggestions([]);
|
||||
@@ -1285,7 +1281,7 @@ export default function CfdiPage() {
|
||||
placeholder="Buscar emisor..."
|
||||
className="h-8 text-sm"
|
||||
value={columnFilters.emisor}
|
||||
onChange={(e) => handleEmisorSearch(e.target.value)}
|
||||
onChange={(e) => setColumnFilters(prev => ({ ...prev, emisor: e.target.value }))}
|
||||
onKeyDown={(e) => e.key === 'Enter' && applyEmisorFilter()}
|
||||
/>
|
||||
{emisorSuggestions.length > 0 && (
|
||||
@@ -1303,7 +1299,7 @@ export default function CfdiPage() {
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{loadingSuggestions && columnFilters.emisor.length >= 2 && (
|
||||
{loadingEmisor && columnFilters.emisor.length >= 2 && emisorSuggestions.length === 0 && (
|
||||
<div className="absolute top-full left-0 right-0 mt-1 bg-white dark:bg-gray-900 border rounded-md shadow-lg p-2 text-center text-sm text-muted-foreground">
|
||||
Buscando...
|
||||
</div>
|
||||
@@ -1342,7 +1338,7 @@ export default function CfdiPage() {
|
||||
placeholder="Buscar receptor..."
|
||||
className="h-8 text-sm"
|
||||
value={columnFilters.receptor}
|
||||
onChange={(e) => handleReceptorSearch(e.target.value)}
|
||||
onChange={(e) => setColumnFilters(prev => ({ ...prev, receptor: e.target.value }))}
|
||||
onKeyDown={(e) => e.key === 'Enter' && applyReceptorFilter()}
|
||||
/>
|
||||
{receptorSuggestions.length > 0 && (
|
||||
@@ -1360,7 +1356,7 @@ export default function CfdiPage() {
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{loadingSuggestions && columnFilters.receptor.length >= 2 && (
|
||||
{loadingReceptor && columnFilters.receptor.length >= 2 && receptorSuggestions.length === 0 && (
|
||||
<div className="absolute top-full left-0 right-0 mt-1 bg-white dark:bg-gray-900 border rounded-md shadow-lg p-2 text-center text-sm text-muted-foreground">
|
||||
Buscando...
|
||||
</div>
|
||||
|
||||
@@ -7,6 +7,8 @@ export function useCfdis(filters: CfdiFilters) {
|
||||
return useQuery({
|
||||
queryKey: ['cfdis', filters],
|
||||
queryFn: () => cfdiApi.getCfdis(filters),
|
||||
staleTime: 30 * 1000, // 30 segundos
|
||||
gcTime: 5 * 60 * 1000, // 5 minutos en cache
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
17
apps/web/lib/hooks/use-debounce.ts
Normal file
17
apps/web/lib/hooks/use-debounce.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
export function useDebounce<T>(value: T, delay: number): T {
|
||||
const [debouncedValue, setDebouncedValue] = useState<T>(value);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = setTimeout(() => {
|
||||
setDebouncedValue(value);
|
||||
}, delay);
|
||||
|
||||
return () => {
|
||||
clearTimeout(handler);
|
||||
};
|
||||
}, [value, delay]);
|
||||
|
||||
return debouncedValue;
|
||||
}
|
||||
Reference in New Issue
Block a user