Merge branch 'feature/scroll-clientes-proveedores' into feature/filtros-cliente-proveedor
This commit is contained in:
@@ -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<TData, TValue> = $props();
|
||||
|
||||
const table = createSvelteTable({
|
||||
@@ -33,9 +37,17 @@
|
||||
|
||||
let scrollContainer = $state<HTMLDivElement>();
|
||||
let loadingTrigger = $state<HTMLDivElement>();
|
||||
let tableContainer = $state<HTMLDivElement>();
|
||||
let bottomScrollbar = $state<HTMLDivElement>();
|
||||
let bottomScrollbarInner = $state<HTMLDivElement>();
|
||||
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());
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="w-full">
|
||||
<div
|
||||
class="rounded-md border min-h-[400px] max-h-[calc(100vh-310px)] overflow-y-auto"
|
||||
class="catalog-table-shell min-h-[400px] max-h-[calc(100vh-310px)] overflow-y-auto [&_[data-slot=table-container]]:w-full [&_[data-slot=table-container]]:overflow-x-auto"
|
||||
bind:this={scrollContainer}
|
||||
>
|
||||
<Table.Root>
|
||||
<Table.Header class="bg-background">
|
||||
<Table.Root class="w-max min-w-[1500px]">
|
||||
<Table.Header class="catalog-table-header">
|
||||
{#each table.getHeaderGroups() as headerGroup (headerGroup.id)}
|
||||
<Table.Row>
|
||||
{#each headerGroup.headers as header (header.id)}
|
||||
<Table.Head>
|
||||
<Table.Head class="whitespace-nowrap">
|
||||
{#if !header.isPlaceholder}
|
||||
<FlexRender
|
||||
content={header.column.columnDef.header}
|
||||
@@ -83,9 +140,13 @@
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{#each table.getRowModel().rows as row (row.id)}
|
||||
<Table.Row data-state={row.getIsSelected() && "selected"}>
|
||||
<Table.Row
|
||||
data-state={row.getIsSelected() && "selected"}
|
||||
class="cursor-pointer transition-colors hover:bg-muted/50 {selectedId === (row.original as any).id ? 'bg-muted' : ''}"
|
||||
onclick={() => onRowClick && onRowClick(row.original)}
|
||||
>
|
||||
{#each row.getVisibleCells() as cell (cell.id)}
|
||||
<Table.Cell>
|
||||
<Table.Cell class="whitespace-nowrap">
|
||||
<FlexRender
|
||||
content={cell.column.columnDef.cell}
|
||||
context={cell.getContext()}
|
||||
@@ -123,4 +184,11 @@
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
</div>
|
||||
<div
|
||||
class="sticky bottom-0 z-30 h-5 overflow-x-scroll overflow-y-hidden border-x border-t bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80 [scrollbar-gutter:stable]"
|
||||
bind:this={bottomScrollbar}
|
||||
onscroll={handleBottomHorizontalScroll}
|
||||
>
|
||||
<div class="h-full min-w-full" bind:this={bottomScrollbarInner}></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -44,8 +44,11 @@
|
||||
let scrollContainer = $state<HTMLDivElement>();
|
||||
let loadingTrigger = $state<HTMLDivElement>();
|
||||
|
||||
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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -35,7 +35,11 @@
|
||||
let loadingTrigger = $state<HTMLDivElement>();
|
||||
|
||||
// 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();
|
||||
|
||||
@@ -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();
|
||||
@@ -24,6 +26,7 @@
|
||||
let items = $state<ClientProvider[]>(data.items || []);
|
||||
let selectedItem = $state<ClientProvider | null>(null);
|
||||
let isLoading = $state(false);
|
||||
let hasMore = $derived(items.length < totalItems);
|
||||
|
||||
// Server-side filtering/pagination parameters
|
||||
let currentPage = $state(data.page || 1);
|
||||
@@ -64,7 +67,7 @@
|
||||
|
||||
// --- Actions ---
|
||||
|
||||
async function loadItems(pageToLoad = 1) {
|
||||
async function loadItems(pageToLoad = 1, append = false) {
|
||||
const companyId = companyStore.activeCompany?.id;
|
||||
if (!companyId) return;
|
||||
|
||||
@@ -72,11 +75,6 @@
|
||||
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;
|
||||
|
||||
@@ -93,7 +91,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,6 +107,11 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMore() {
|
||||
if (isLoading || !hasMore) return;
|
||||
await loadItems(currentPage + 1, true);
|
||||
}
|
||||
|
||||
function handleTypeChange(value: string) {
|
||||
searchType = value;
|
||||
loadItems(1);
|
||||
@@ -250,95 +257,16 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-auto bg-card">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="bg-muted text-muted-foreground border-b">
|
||||
<tr>
|
||||
<th class="px-3 py-2 text-left w-8">#</th>
|
||||
<th class="px-3 py-2 text-left">RFC / TAX-ID</th>
|
||||
<th class="px-3 py-2 text-left">Nombre</th>
|
||||
<th class="px-3 py-2 text-left">Tipo</th>
|
||||
<th class="px-3 py-2 text-left">Estatus</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#if isLoading}
|
||||
<tr
|
||||
><td colspan="5" class="text-center py-8 text-muted-foreground">Cargando...</td
|
||||
></tr
|
||||
>
|
||||
{:else if items.length === 0}
|
||||
<tr
|
||||
><td colspan="5" class="text-center py-8 text-muted-foreground"
|
||||
>No se encontraron registros</td
|
||||
></tr
|
||||
>
|
||||
{:else}
|
||||
{#each items as item (item.id)}
|
||||
<tr
|
||||
class="border-b cursor-pointer transition-colors hover:bg-muted/50 {selectedItem?.id ===
|
||||
item.id
|
||||
? 'bg-muted'
|
||||
: ''}"
|
||||
onclick={() => selectItem(item)}
|
||||
>
|
||||
<td class="px-3 py-2 font-mono text-xs text-muted-foreground">{item.id}</td>
|
||||
<td class="px-3 py-2 font-mono font-medium">{item.rfc}</td>
|
||||
<td class="px-3 py-2">{item.name}</td>
|
||||
<td class="px-3 py-2">
|
||||
{#if item.client_or_provider === 'client'}
|
||||
<span
|
||||
class="inline-flex items-center rounded-full bg-blue-100 px-2 py-0.5 text-xs font-medium text-blue-700"
|
||||
>Cliente</span
|
||||
>
|
||||
{:else if item.client_or_provider === 'provider'}
|
||||
<span
|
||||
class="inline-flex items-center rounded-full bg-purple-100 px-2 py-0.5 text-xs font-medium text-purple-700"
|
||||
>Proveedor</span
|
||||
>
|
||||
{:else}
|
||||
<span
|
||||
class="inline-flex items-center rounded-full bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-700"
|
||||
>Ambos</span
|
||||
>
|
||||
{/if}
|
||||
</td>
|
||||
<td class="px-3 py-2">
|
||||
<span
|
||||
class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium {item.is_active
|
||||
? 'bg-green-100 text-green-700'
|
||||
: 'bg-red-100 text-red-700'}"
|
||||
>
|
||||
{item.is_active ? 'Activo' : 'Inactivo'}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
{/if}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!-- Simple Pagination Controls -->
|
||||
<div class="p-2 border-t flex justify-end gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={currentPage === 1 || isLoading}
|
||||
onclick={() => loadItems(currentPage - 1)}
|
||||
>
|
||||
Anterior
|
||||
</Button>
|
||||
<span class="flex items-center text-xs text-muted-foreground px-2">
|
||||
Página {currentPage} de {Math.ceil(totalItems / pageSize)}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={items.length < pageSize || isLoading}
|
||||
onclick={() => loadItems(currentPage + 1)}
|
||||
>
|
||||
Siguiente
|
||||
</Button>
|
||||
<div class="flex-1 overflow-hidden bg-card">
|
||||
<DataTable
|
||||
data={items}
|
||||
columns={createColumns(() => loadItems(1))}
|
||||
loading={isLoading}
|
||||
{hasMore}
|
||||
{loadMore}
|
||||
onRowClick={(row) => selectItem(row as ClientProvider)}
|
||||
selectedId={selectedItem?.id}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user