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}%`);
|
params.push(`%${filters.search}%`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const countResult = await prisma.$queryRawUnsafe<[{ count: number }]>(`
|
// Combinar COUNT con la query principal usando window function
|
||||||
SELECT COUNT(*) as count FROM "${schema}".cfdis ${whereClause}
|
|
||||||
`, ...params);
|
|
||||||
|
|
||||||
const total = Number(countResult[0]?.count || 0);
|
|
||||||
|
|
||||||
params.push(limit, offset);
|
params.push(limit, offset);
|
||||||
const data = await prisma.$queryRawUnsafe<Cfdi[]>(`
|
const dataWithCount = await prisma.$queryRawUnsafe<(Cfdi & { total_count: number })[]>(`
|
||||||
SELECT
|
SELECT
|
||||||
id, uuid_fiscal as "uuidFiscal", tipo, serie, folio,
|
id, uuid_fiscal as "uuidFiscal", tipo, serie, folio,
|
||||||
fecha_emision as "fechaEmision", fecha_timbrado as "fechaTimbrado",
|
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",
|
tipo_cambio as "tipoCambio", metodo_pago as "metodoPago",
|
||||||
forma_pago as "formaPago", uso_cfdi as "usoCfdi",
|
forma_pago as "formaPago", uso_cfdi as "usoCfdi",
|
||||||
estado, xml_url as "xmlUrl", pdf_url as "pdfUrl",
|
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
|
FROM "${schema}".cfdis
|
||||||
${whereClause}
|
${whereClause}
|
||||||
ORDER BY fecha_emision DESC
|
ORDER BY fecha_emision DESC
|
||||||
LIMIT $${paramIndex++} OFFSET $${paramIndex}
|
LIMIT $${paramIndex++} OFFSET $${paramIndex}
|
||||||
`, ...params);
|
`, ...params);
|
||||||
|
|
||||||
|
const total = Number(dataWithCount[0]?.total_count || 0);
|
||||||
|
const data = dataWithCount.map(({ total_count, ...cfdi }) => cfdi) as Cfdi[];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
data,
|
data,
|
||||||
total,
|
total,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
'use client';
|
'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 { Header } from '@/components/layouts/header';
|
||||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@@ -240,8 +241,39 @@ export default function CfdiPage() {
|
|||||||
const [openFilter, setOpenFilter] = useState<'fecha' | 'emisor' | 'receptor' | null>(null);
|
const [openFilter, setOpenFilter] = useState<'fecha' | 'emisor' | 'receptor' | null>(null);
|
||||||
const [emisorSuggestions, setEmisorSuggestions] = useState<EmisorReceptor[]>([]);
|
const [emisorSuggestions, setEmisorSuggestions] = useState<EmisorReceptor[]>([]);
|
||||||
const [receptorSuggestions, setReceptorSuggestions] = 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);
|
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 [showBulkForm, setShowBulkForm] = useState(false);
|
||||||
const [formData, setFormData] = useState<CreateCfdiData>(initialFormData);
|
const [formData, setFormData] = useState<CreateCfdiData>(initialFormData);
|
||||||
const [bulkData, setBulkData] = useState('');
|
const [bulkData, setBulkData] = useState('');
|
||||||
@@ -291,42 +323,6 @@ export default function CfdiPage() {
|
|||||||
setFilters({ ...filters, search: searchTerm, page: 1 });
|
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) => {
|
const selectEmisor = (emisor: EmisorReceptor) => {
|
||||||
setColumnFilters(prev => ({ ...prev, emisor: emisor.nombre }));
|
setColumnFilters(prev => ({ ...prev, emisor: emisor.nombre }));
|
||||||
setEmisorSuggestions([]);
|
setEmisorSuggestions([]);
|
||||||
@@ -1285,7 +1281,7 @@ export default function CfdiPage() {
|
|||||||
placeholder="Buscar emisor..."
|
placeholder="Buscar emisor..."
|
||||||
className="h-8 text-sm"
|
className="h-8 text-sm"
|
||||||
value={columnFilters.emisor}
|
value={columnFilters.emisor}
|
||||||
onChange={(e) => handleEmisorSearch(e.target.value)}
|
onChange={(e) => setColumnFilters(prev => ({ ...prev, emisor: e.target.value }))}
|
||||||
onKeyDown={(e) => e.key === 'Enter' && applyEmisorFilter()}
|
onKeyDown={(e) => e.key === 'Enter' && applyEmisorFilter()}
|
||||||
/>
|
/>
|
||||||
{emisorSuggestions.length > 0 && (
|
{emisorSuggestions.length > 0 && (
|
||||||
@@ -1303,7 +1299,7 @@ export default function CfdiPage() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</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">
|
<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...
|
Buscando...
|
||||||
</div>
|
</div>
|
||||||
@@ -1342,7 +1338,7 @@ export default function CfdiPage() {
|
|||||||
placeholder="Buscar receptor..."
|
placeholder="Buscar receptor..."
|
||||||
className="h-8 text-sm"
|
className="h-8 text-sm"
|
||||||
value={columnFilters.receptor}
|
value={columnFilters.receptor}
|
||||||
onChange={(e) => handleReceptorSearch(e.target.value)}
|
onChange={(e) => setColumnFilters(prev => ({ ...prev, receptor: e.target.value }))}
|
||||||
onKeyDown={(e) => e.key === 'Enter' && applyReceptorFilter()}
|
onKeyDown={(e) => e.key === 'Enter' && applyReceptorFilter()}
|
||||||
/>
|
/>
|
||||||
{receptorSuggestions.length > 0 && (
|
{receptorSuggestions.length > 0 && (
|
||||||
@@ -1360,7 +1356,7 @@ export default function CfdiPage() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</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">
|
<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...
|
Buscando...
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ export function useCfdis(filters: CfdiFilters) {
|
|||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['cfdis', filters],
|
queryKey: ['cfdis', filters],
|
||||||
queryFn: () => cfdiApi.getCfdis(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