feat(cfdi): add autocomplete for emisor and receptor filters
- Add /cfdi/emisores and /cfdi/receptores API endpoints - Search by RFC or nombre with ILIKE - Show suggestions dropdown while typing (min 2 chars) - Click suggestion to select and populate filter input - Show loading state while searching Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -67,6 +67,42 @@ export async function getXml(req: Request, res: Response, next: NextFunction) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getEmisores(req: Request, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
if (!req.tenantSchema) {
|
||||||
|
return next(new AppError(400, 'Schema no configurado'));
|
||||||
|
}
|
||||||
|
|
||||||
|
const search = (req.query.search as string) || '';
|
||||||
|
if (search.length < 2) {
|
||||||
|
return res.json([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const emisores = await cfdiService.getEmisores(req.tenantSchema, search);
|
||||||
|
res.json(emisores);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getReceptores(req: Request, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
if (!req.tenantSchema) {
|
||||||
|
return next(new AppError(400, 'Schema no configurado'));
|
||||||
|
}
|
||||||
|
|
||||||
|
const search = (req.query.search as string) || '';
|
||||||
|
if (search.length < 2) {
|
||||||
|
return res.json([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const receptores = await cfdiService.getReceptores(req.tenantSchema, search);
|
||||||
|
res.json(receptores);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function getResumen(req: Request, res: Response, next: NextFunction) {
|
export async function getResumen(req: Request, res: Response, next: NextFunction) {
|
||||||
try {
|
try {
|
||||||
if (!req.tenantSchema) {
|
if (!req.tenantSchema) {
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ router.use(tenantMiddleware);
|
|||||||
|
|
||||||
router.get('/', cfdiController.getCfdis);
|
router.get('/', cfdiController.getCfdis);
|
||||||
router.get('/resumen', cfdiController.getResumen);
|
router.get('/resumen', cfdiController.getResumen);
|
||||||
|
router.get('/emisores', cfdiController.getEmisores);
|
||||||
|
router.get('/receptores', cfdiController.getReceptores);
|
||||||
router.get('/:id', cfdiController.getCfdiById);
|
router.get('/:id', cfdiController.getCfdiById);
|
||||||
router.get('/:id/xml', cfdiController.getXml);
|
router.get('/:id/xml', cfdiController.getXml);
|
||||||
router.post('/', cfdiController.createCfdi);
|
router.post('/', cfdiController.createCfdi);
|
||||||
|
|||||||
@@ -387,6 +387,28 @@ export async function deleteCfdi(schema: string, id: string): Promise<void> {
|
|||||||
await prisma.$queryRawUnsafe(`DELETE FROM "${schema}".cfdis WHERE id = $1`, id);
|
await prisma.$queryRawUnsafe(`DELETE FROM "${schema}".cfdis WHERE id = $1`, id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getEmisores(schema: string, search: string, limit: number = 10): Promise<{ rfc: string; nombre: string }[]> {
|
||||||
|
const result = await prisma.$queryRawUnsafe<{ rfc: string; nombre: string }[]>(`
|
||||||
|
SELECT DISTINCT rfc_emisor as rfc, nombre_emisor as nombre
|
||||||
|
FROM "${schema}".cfdis
|
||||||
|
WHERE rfc_emisor ILIKE $1 OR nombre_emisor ILIKE $1
|
||||||
|
ORDER BY nombre_emisor
|
||||||
|
LIMIT $2
|
||||||
|
`, `%${search}%`, limit);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getReceptores(schema: string, search: string, limit: number = 10): Promise<{ rfc: string; nombre: string }[]> {
|
||||||
|
const result = await prisma.$queryRawUnsafe<{ rfc: string; nombre: string }[]>(`
|
||||||
|
SELECT DISTINCT rfc_receptor as rfc, nombre_receptor as nombre
|
||||||
|
FROM "${schema}".cfdis
|
||||||
|
WHERE rfc_receptor ILIKE $1 OR nombre_receptor ILIKE $1
|
||||||
|
ORDER BY nombre_receptor
|
||||||
|
LIMIT $2
|
||||||
|
`, `%${search}%`, limit);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
export async function getResumenCfdis(schema: string, año: number, mes: number) {
|
export async function getResumenCfdis(schema: string, año: number, mes: number) {
|
||||||
const result = await prisma.$queryRawUnsafe<[{
|
const result = await prisma.$queryRawUnsafe<[{
|
||||||
total_ingresos: number;
|
total_ingresos: number;
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { Input } from '@/components/ui/input';
|
|||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
import { useCfdis, useCreateCfdi, useDeleteCfdi } from '@/lib/hooks/use-cfdi';
|
import { useCfdis, useCreateCfdi, useDeleteCfdi } from '@/lib/hooks/use-cfdi';
|
||||||
import { createManyCfdis } from '@/lib/api/cfdi';
|
import { createManyCfdis, searchEmisores, searchReceptores, type EmisorReceptor } from '@/lib/api/cfdi';
|
||||||
import type { CfdiFilters, TipoCfdi, Cfdi } from '@horux/shared';
|
import type { CfdiFilters, TipoCfdi, Cfdi } from '@horux/shared';
|
||||||
import type { CreateCfdiData } from '@/lib/api/cfdi';
|
import type { CreateCfdiData } from '@/lib/api/cfdi';
|
||||||
import { FileText, Search, ChevronLeft, ChevronRight, Plus, Upload, Trash2, X, FileUp, CheckCircle, AlertCircle, Loader2, Eye, Filter, XCircle, Calendar, User, Building2 } from 'lucide-react';
|
import { FileText, Search, ChevronLeft, ChevronRight, Plus, Upload, Trash2, X, FileUp, CheckCircle, AlertCircle, Loader2, Eye, Filter, XCircle, Calendar, User, Building2 } from 'lucide-react';
|
||||||
@@ -238,6 +238,9 @@ export default function CfdiPage() {
|
|||||||
receptor: '',
|
receptor: '',
|
||||||
});
|
});
|
||||||
const [openFilter, setOpenFilter] = useState<'fecha' | 'emisor' | 'receptor' | null>(null);
|
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 [showForm, setShowForm] = useState(false);
|
const [showForm, setShowForm] = useState(false);
|
||||||
const [showBulkForm, setShowBulkForm] = useState(false);
|
const [showBulkForm, setShowBulkForm] = useState(false);
|
||||||
const [formData, setFormData] = useState<CreateCfdiData>(initialFormData);
|
const [formData, setFormData] = useState<CreateCfdiData>(initialFormData);
|
||||||
@@ -288,6 +291,52 @@ 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) => {
|
||||||
|
setColumnFilters(prev => ({ ...prev, emisor: emisor.nombre }));
|
||||||
|
setEmisorSuggestions([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectReceptor = (receptor: EmisorReceptor) => {
|
||||||
|
setColumnFilters(prev => ({ ...prev, receptor: receptor.nombre }));
|
||||||
|
setReceptorSuggestions([]);
|
||||||
|
};
|
||||||
|
|
||||||
const applyDateFilter = () => {
|
const applyDateFilter = () => {
|
||||||
setFilters({
|
setFilters({
|
||||||
...filters,
|
...filters,
|
||||||
@@ -1227,18 +1276,38 @@ export default function CfdiPage() {
|
|||||||
<Filter className="h-3.5 w-3.5" />
|
<Filter className="h-3.5 w-3.5" />
|
||||||
</button>
|
</button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent className="w-64" align="start">
|
<PopoverContent className="w-72" align="start">
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<h4 className="font-medium text-sm">Filtrar por emisor</h4>
|
<h4 className="font-medium text-sm">Filtrar por emisor</h4>
|
||||||
<div>
|
<div className="relative">
|
||||||
<Label className="text-xs">RFC o Nombre</Label>
|
<Label className="text-xs">RFC o Nombre</Label>
|
||||||
<Input
|
<Input
|
||||||
placeholder="Buscar emisor..."
|
placeholder="Buscar emisor..."
|
||||||
className="h-8 text-sm"
|
className="h-8 text-sm"
|
||||||
value={columnFilters.emisor}
|
value={columnFilters.emisor}
|
||||||
onChange={(e) => setColumnFilters({ ...columnFilters, emisor: e.target.value })}
|
onChange={(e) => handleEmisorSearch(e.target.value)}
|
||||||
onKeyDown={(e) => e.key === 'Enter' && applyEmisorFilter()}
|
onKeyDown={(e) => e.key === 'Enter' && applyEmisorFilter()}
|
||||||
/>
|
/>
|
||||||
|
{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 max-h-48 overflow-y-auto z-50">
|
||||||
|
{emisorSuggestions.map((emisor, idx) => (
|
||||||
|
<button
|
||||||
|
key={idx}
|
||||||
|
type="button"
|
||||||
|
className="w-full px-3 py-2 text-left text-sm hover:bg-muted transition-colors border-b last:border-b-0"
|
||||||
|
onClick={() => selectEmisor(emisor)}
|
||||||
|
>
|
||||||
|
<p className="font-medium truncate">{emisor.nombre}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">{emisor.rfc}</p>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{loadingSuggestions && columnFilters.emisor.length >= 2 && (
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button size="sm" className="flex-1" onClick={applyEmisorFilter}>
|
<Button size="sm" className="flex-1" onClick={applyEmisorFilter}>
|
||||||
@@ -1264,18 +1333,38 @@ export default function CfdiPage() {
|
|||||||
<Filter className="h-3.5 w-3.5" />
|
<Filter className="h-3.5 w-3.5" />
|
||||||
</button>
|
</button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent className="w-64" align="start">
|
<PopoverContent className="w-72" align="start">
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<h4 className="font-medium text-sm">Filtrar por receptor</h4>
|
<h4 className="font-medium text-sm">Filtrar por receptor</h4>
|
||||||
<div>
|
<div className="relative">
|
||||||
<Label className="text-xs">RFC o Nombre</Label>
|
<Label className="text-xs">RFC o Nombre</Label>
|
||||||
<Input
|
<Input
|
||||||
placeholder="Buscar receptor..."
|
placeholder="Buscar receptor..."
|
||||||
className="h-8 text-sm"
|
className="h-8 text-sm"
|
||||||
value={columnFilters.receptor}
|
value={columnFilters.receptor}
|
||||||
onChange={(e) => setColumnFilters({ ...columnFilters, receptor: e.target.value })}
|
onChange={(e) => handleReceptorSearch(e.target.value)}
|
||||||
onKeyDown={(e) => e.key === 'Enter' && applyReceptorFilter()}
|
onKeyDown={(e) => e.key === 'Enter' && applyReceptorFilter()}
|
||||||
/>
|
/>
|
||||||
|
{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 max-h-48 overflow-y-auto z-50">
|
||||||
|
{receptorSuggestions.map((receptor, idx) => (
|
||||||
|
<button
|
||||||
|
key={idx}
|
||||||
|
type="button"
|
||||||
|
className="w-full px-3 py-2 text-left text-sm hover:bg-muted transition-colors border-b last:border-b-0"
|
||||||
|
onClick={() => selectReceptor(receptor)}
|
||||||
|
>
|
||||||
|
<p className="font-medium truncate">{receptor.nombre}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">{receptor.rfc}</p>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{loadingSuggestions && columnFilters.receptor.length >= 2 && (
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button size="sm" className="flex-1" onClick={applyReceptorFilter}>
|
<Button size="sm" className="flex-1" onClick={applyReceptorFilter}>
|
||||||
|
|||||||
@@ -98,3 +98,20 @@ export async function getCfdiXml(id: string): Promise<string> {
|
|||||||
});
|
});
|
||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface EmisorReceptor {
|
||||||
|
rfc: string;
|
||||||
|
nombre: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function searchEmisores(search: string): Promise<EmisorReceptor[]> {
|
||||||
|
if (search.length < 2) return [];
|
||||||
|
const response = await apiClient.get<EmisorReceptor[]>(`/cfdi/emisores?search=${encodeURIComponent(search)}`);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function searchReceptores(search: string): Promise<EmisorReceptor[]> {
|
||||||
|
if (search.length < 2) return [];
|
||||||
|
const response = await apiClient.get<EmisorReceptor[]>(`/cfdi/receptores?search=${encodeURIComponent(search)}`);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user