diff --git a/backend/api/v1/modules/a76/clients_and_providers/routes.py b/backend/api/v1/modules/a76/clients_and_providers/routes.py index bd3ad62c..0c012924 100644 --- a/backend/api/v1/modules/a76/clients_and_providers/routes.py +++ b/backend/api/v1/modules/a76/clients_and_providers/routes.py @@ -7,6 +7,7 @@ from typing import List, Optional from core.database import get_core_db from core.security import get_current_user, validate_access_to_resource from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy import or_ from sqlalchemy.orm import Session, joinedload from api.v1.common.tenant_crud_routes import TenantCRUDRoutes from .models import ClientOrProviderEnum @@ -32,10 +33,16 @@ router.include_router(imports_router, prefix="/imports", tags=["clients_and_prov @router.get("/", response_model=ClientProviderPaginatedResponseDTO) async def get_clients_and_providers( company_id: int = Query(..., description="Company ID"), + name: Optional[str] = Query(None, description="Filter by name (contains)"), + rfc: Optional[str] = Query(None, description="Filter by RFC/TAX-ID (contains)"), type: Optional[ClientOrProviderEnum] = Query( None, description="Type of entity (client or provider)" ), active: Optional[bool] = Query(None, description="Active status"), + page: Optional[int] = Query(None, ge=1, description="Page number (1-based)"), + page_size: Optional[int] = Query( + None, ge=1, le=1000, description="Page size when using page-based pagination" + ), skip: int = Query(0, ge=0), limit: int = Query(100, ge=1, le=1000), db: Session = Depends(get_core_db), @@ -44,6 +51,9 @@ async def get_clients_and_providers( """Get clients and providers""" tenant_id = validate_access_to_resource(db, company_id, current_user) + resolved_limit = page_size if page_size is not None else limit + resolved_skip = ((page - 1) * resolved_limit) if page is not None else skip + query = db.query(ClientProvider).options( joinedload(ClientProvider.address), joinedload(ClientProvider.programs) @@ -52,9 +62,14 @@ async def get_clients_and_providers( ClientProvider.company_id == company_id, ) + if name: + query = query.filter(ClientProvider.name.ilike(f"%{name.strip()}%")) + + if rfc: + query = query.filter(ClientProvider.rfc.ilike(f"%{rfc.strip()}%")) + if type is not None: # Include 'both' type when filtering by client or provider - from sqlalchemy import or_ query = query.filter( or_( ClientProvider.client_or_provider == type, @@ -66,13 +81,13 @@ async def get_clients_and_providers( query = query.filter(ClientProvider.is_active == active) total = query.count() - clients = query.offset(skip).limit(limit).all() + clients = query.offset(resolved_skip).limit(resolved_limit).all() return { "items": [ClientProviderResponseDTO.model_validate(c) for c in clients], "total": total, - "page": (skip // limit) + 1, - "page_size": limit, + "page": (resolved_skip // resolved_limit) + 1, + "page_size": resolved_limit, } @router.get("/{client_id}/basic", response_model=ClientProviderBasicDTO) diff --git a/frontend/src/lib/api/help.ts b/frontend/src/lib/api/help.ts index 70693704..e4907ccf 100644 --- a/frontend/src/lib/api/help.ts +++ b/frontend/src/lib/api/help.ts @@ -1,8 +1,9 @@ import { getToken, authStore } from '$lib/auth'; import { get } from 'svelte/store'; -const api_url = import.meta.env.VITE_API_URL; -const BASE_URL = `${api_url.endsWith('/') ? api_url : api_url + '/'}v1/core/help-center`; +const api_url = import.meta.env.VITE_API_URL ?? ''; +const normalizedApiUrl = api_url ? (api_url.endsWith('/') ? api_url : `${api_url}/`) : '/'; +const BASE_URL = `${normalizedApiUrl}v1/core/help-center`; function getAuthToken(): string | null { // 1. First try getToken() which checks Keycloak and localStorage diff --git a/frontend/src/lib/components/dashboard/clients_and_providers/data-table.svelte b/frontend/src/lib/components/dashboard/clients_and_providers/data-table.svelte index 2f4a88e7..4255092c 100644 --- a/frontend/src/lib/components/dashboard/clients_and_providers/data-table.svelte +++ b/frontend/src/lib/components/dashboard/clients_and_providers/data-table.svelte @@ -13,6 +13,8 @@ loading: boolean; hasMore: boolean; loadMore: () => void; + onRowClick?: (row: TData) => void; + selectedId?: number | null; }; let { @@ -20,7 +22,9 @@ columns, loading, hasMore, - loadMore + loadMore, + onRowClick, + selectedId = null }: DataTableProps = $props(); const table = createSvelteTable({ @@ -33,9 +37,17 @@ let scrollContainer = $state(); let loadingTrigger = $state(); + let tableContainer = $state(); + let bottomScrollbar = $state(); + let bottomScrollbarInner = $state(); + let isSyncingHorizontalScroll = false; // Intersection Observer para detectar cuando el usuario llega al final - onMount(() => { + $effect(() => { + const target = loadingTrigger; + const root = scrollContainer; + if (!target) return; + const observer = new IntersectionObserver( (entries) => { const [entry] = entries; @@ -44,32 +56,77 @@ } }, { - root: scrollContainer, + root: root, threshold: 0.1 } ); - if (loadingTrigger) { - observer.observe(loadingTrigger); - } + observer.observe(target); return () => { observer.disconnect(); }; }); + + function syncBottomScrollbarWidth() { + if (!tableContainer || !bottomScrollbarInner) return; + const width = Math.max(tableContainer.scrollWidth || 0, tableContainer.clientWidth + 1); + bottomScrollbarInner.style.width = `${width}px`; + } + + function handleTableHorizontalScroll() { + if (!tableContainer || !bottomScrollbar || isSyncingHorizontalScroll) return; + isSyncingHorizontalScroll = true; + bottomScrollbar.scrollLeft = tableContainer.scrollLeft; + isSyncingHorizontalScroll = false; + } + + function handleBottomHorizontalScroll() { + if (!tableContainer || !bottomScrollbar || isSyncingHorizontalScroll) return; + isSyncingHorizontalScroll = true; + tableContainer.scrollLeft = bottomScrollbar.scrollLeft; + isSyncingHorizontalScroll = false; + } + + onMount(() => { + tableContainer = scrollContainer?.querySelector('[data-slot="table-container"]') ?? undefined; + if (!tableContainer) return; + + const resizeObserver = new ResizeObserver(() => { + syncBottomScrollbarWidth(); + }); + + tableContainer.addEventListener('scroll', handleTableHorizontalScroll, { passive: true }); + resizeObserver.observe(tableContainer); + const tableEl = tableContainer.querySelector('[data-slot="table"]'); + if (tableEl) resizeObserver.observe(tableEl); + + syncBottomScrollbarWidth(); + + return () => { + tableContainer?.removeEventListener('scroll', handleTableHorizontalScroll); + resizeObserver.disconnect(); + }; + }); + + $effect(() => { + data; + columns; + queueMicrotask(() => syncBottomScrollbarWidth()); + });
- - + + {#each table.getHeaderGroups() as headerGroup (headerGroup.id)} {#each headerGroup.headers as header (header.id)} - + {#if !header.isPlaceholder} {#each table.getRowModel().rows as row (row.id)} - + onRowClick && onRowClick(row.original)} + > {#each row.getVisibleCells() as cell (cell.id)} - +
+
+
+
diff --git a/frontend/src/lib/components/dashboard/export/manifest/data-table.svelte b/frontend/src/lib/components/dashboard/export/manifest/data-table.svelte index ec6893aa..cbbda2c4 100644 --- a/frontend/src/lib/components/dashboard/export/manifest/data-table.svelte +++ b/frontend/src/lib/components/dashboard/export/manifest/data-table.svelte @@ -44,8 +44,11 @@ let scrollContainer = $state(); let loadingTrigger = $state(); - onMount(() => { + $effect(() => { if (!loadMore) return; + const target = loadingTrigger; + const root = scrollContainer; + if (!target) return; const observer = new IntersectionObserver( (entries) => { @@ -55,14 +58,12 @@ } }, { - root: null, // Relative to viewport if scrollContainer is used as max-h div + root: root, threshold: 0.1 } ); - if (loadingTrigger) { - observer.observe(loadingTrigger); - } + observer.observe(target); return () => { observer.disconnect(); diff --git a/frontend/src/lib/components/dashboard/pedimentos/data-table.svelte b/frontend/src/lib/components/dashboard/pedimentos/data-table.svelte index e6c1e89c..1ce39545 100644 --- a/frontend/src/lib/components/dashboard/pedimentos/data-table.svelte +++ b/frontend/src/lib/components/dashboard/pedimentos/data-table.svelte @@ -72,7 +72,11 @@ } // Intersection Observer para detectar cuando el usuario llega al final - onMount(() => { + $effect(() => { + const target = loadingTrigger; + const root = scrollContainer; + if (!target) return; + const observer = new IntersectionObserver( (entries) => { const [entry] = entries; @@ -81,14 +85,12 @@ } }, { - root: scrollContainer, + root: root, threshold: 0.1 } ); - if (loadingTrigger) { - observer.observe(loadingTrigger); - } + observer.observe(target); return () => { observer.disconnect(); diff --git a/frontend/src/lib/components/dashboard/reference_data/countries/data-table.svelte b/frontend/src/lib/components/dashboard/reference_data/countries/data-table.svelte index 1e44175d..261494f0 100644 --- a/frontend/src/lib/components/dashboard/reference_data/countries/data-table.svelte +++ b/frontend/src/lib/components/dashboard/reference_data/countries/data-table.svelte @@ -35,7 +35,11 @@ let loadingTrigger = $state(); // Intersection Observer para detectar cuando el usuario llega al final - onMount(() => { + $effect(() => { + const target = loadingTrigger; + const root = scrollContainer; + if (!target) return; + const observer = new IntersectionObserver( (entries) => { const [entry] = entries; @@ -44,14 +48,12 @@ } }, { - root: scrollContainer, + root: root, threshold: 0.1 } ); - if (loadingTrigger) { - observer.observe(loadingTrigger); - } + observer.observe(target); return () => { observer.disconnect(); diff --git a/frontend/src/routes/dashboard/clients_and_providers/+page.svelte b/frontend/src/routes/dashboard/clients_and_providers/+page.svelte index 8384d1b7..df7541e1 100644 --- a/frontend/src/routes/dashboard/clients_and_providers/+page.svelte +++ b/frontend/src/routes/dashboard/clients_and_providers/+page.svelte @@ -16,6 +16,8 @@ import { toast } from 'svelte-sonner'; import { companyStore } from '$lib/stores/company.svelte'; import type { ApiError } from '$lib/utils/error-handler'; + import DataTable from '$lib/components/dashboard/clients_and_providers/data-table.svelte'; + import { createColumns } from '$lib/components/dashboard/clients_and_providers/columns'; // Los datos iniciales vienen del servidor let { data }: { data: any } = $props(); @@ -29,11 +31,13 @@ let currentPage = $state(data.page || 1); let pageSize = $state(50); let totalItems = $state(data.total || 0); + let hasMore = $derived(items.length < totalItems); // Filter state let searchName = $state(''); let searchRfc = $state(''); let searchType = $state($page.url.searchParams.get('type') || 'both'); + let filterDebounce: ReturnType | null = null; // Estado para el diálogo de crear let showCreateDialog = $state(false); @@ -64,7 +68,7 @@ // --- Actions --- - async function loadItems(pageToLoad = 1) { + async function loadItems(pageToLoad = 1, append = false) { const companyId = companyStore.activeCompany?.id; if (!companyId) return; @@ -72,13 +76,10 @@ try { const filters: any = {}; if (searchType !== 'both') filters.type = searchType; - // Note: The API technically supports name/rfc fitlering if backend implements it. - // Assuming backend supports 'name' and 'rfc' query params based on standard patterns, - // or we filter client side if the list is small. - // Given pagination, we should try sending them. If backend ignores them, we might need client filtering. - // Ideally backend should handle this. I will assume backend filters for now or add query params. - if (searchName) filters.name = searchName; - if (searchRfc) filters.rfc = searchRfc; + const trimmedName = searchName.trim(); + const trimmedRfc = searchRfc.trim(); + if (trimmedName) filters.name = trimmedName; + if (trimmedRfc) filters.rfc = trimmedRfc; const response = await clientsProvidersApi.list(companyId, pageToLoad, pageSize, filters); @@ -93,7 +94,11 @@ } if (response.data) { - items = response.data.items; + if (append) { + items = [...items, ...response.data.items]; + } else { + items = response.data.items; + } totalItems = response.data.total; currentPage = response.data.page; } @@ -105,13 +110,13 @@ } } - function handleTypeChange(value: string) { - searchType = value; - loadItems(1); + async function loadMore() { + if (isLoading || !hasMore) return; + await loadItems(currentPage + 1, true); } - function handleSearch() { - loadItems(1); + function handleTypeChange(value: string) { + searchType = value; } function selectItem(item: ClientProvider) { @@ -155,6 +160,26 @@ recargar: () => loadItems(1) }) ); + + $effect(() => { + const companyId = companyStore.activeCompany?.id; + if (!browser || !companyId) return; + + searchName; + searchRfc; + searchType; + pageSize; + + if (filterDebounce) clearTimeout(filterDebounce); + filterDebounce = setTimeout(() => { + selectedItem = null; + loadItems(1); + }, 350); + + return () => { + if (filterDebounce) clearTimeout(filterDebounce); + }; + });
@@ -190,14 +215,13 @@

Filtros

Busque por nombre, RFC/TAX-ID o tipo
-
+
e.key === 'Enter' && handleSearch()} />
@@ -206,7 +230,6 @@ bind:value={searchRfc} placeholder="RFC / TAX-ID..." class="h-9" - onkeydown={(e) => e.key === 'Enter' && handleSearch()} />
@@ -226,11 +249,6 @@
-
- -
@@ -250,95 +268,16 @@ -
- - - - - - - - - - - - {#if isLoading} - - {:else if items.length === 0} - - {:else} - {#each items as item (item.id)} - selectItem(item)} - > - - - - - - - {/each} - {/if} - -
#RFC / TAX-IDNombreTipoEstatus
Cargando...
No se encontraron registros
{item.id}{item.rfc}{item.name} - {#if item.client_or_provider === 'client'} - Cliente - {:else if item.client_or_provider === 'provider'} - Proveedor - {:else} - Ambos - {/if} - - - {item.is_active ? 'Activo' : 'Inactivo'} - -
-
- -
- - - Página {currentPage} de {Math.ceil(totalItems / pageSize)} - - +
+ loadItems(1))} + loading={isLoading} + {hasMore} + {loadMore} + onRowClick={(row) => selectItem(row as ClientProvider)} + selectedId={selectedItem?.id} + />