feat: Add state and sector selector dialogs with search and infinite scroll capabilities, and update gitignore to include mypy cache.

This commit is contained in:
Galindo97
2026-02-24 16:57:22 -06:00
parent ccb643426d
commit 2028c87645
3 changed files with 451 additions and 0 deletions

1
.gitignore vendored
View File

@@ -1,5 +1,6 @@
# Python
__pycache__/
.mypy_cache/
*.py[cod]
*$py.class
*.so

View File

@@ -0,0 +1,226 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import * as Dialog from '$lib/components/ui/dialog';
import * as Table from '$lib/components/ui/table';
import { Search, Loader2, Factory } from 'lucide-svelte';
import { sectorsApi, type Sector } from '$lib/api/dashboard/reference_data/sectors';
import { toast } from 'svelte-sonner';
// --- PROPS ---
let {
open = $bindable(false),
onSelect
}: {
open: boolean;
onSelect: (item: Sector) => void;
} = $props();
// --- ESTADO ---
let items = $state<Sector[]>([]);
let loading = $state(false);
let loadingMore = $state(false);
let searchTerm = $state('');
let previousSearchTerm = '';
let page = $state(1);
let pageSize = 50;
let hasMore = $state(true);
let totalItems = $state(0);
let observer: IntersectionObserver | null = null;
let bottomSentinel: HTMLElement | null = $state(null);
let searchTimeout: any;
let isInitialized = false;
// Cargar datos iniciales al abrir
$effect(() => {
if (open && !isInitialized) {
isInitialized = true;
previousSearchTerm = searchTerm;
resetAndLoad();
} else if (!open) {
isInitialized = false;
}
});
// Manejar búsqueda con debouncing
$effect(() => {
const term = searchTerm;
if (isInitialized && term !== previousSearchTerm) {
if (searchTimeout) clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
previousSearchTerm = term;
resetAndLoad();
}, 500);
}
});
// Configurar IntersectionObserver para infinite scroll
$effect(() => {
if (bottomSentinel && hasMore && !loading && !loadingMore && open) {
if (observer) observer.disconnect();
observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && hasMore && !loading && !loadingMore) {
loadMore();
}
},
{ threshold: 0.1 }
);
observer.observe(bottomSentinel);
}
return () => {
if (observer) observer.disconnect();
};
});
async function resetAndLoad() {
page = 1;
items = [];
hasMore = true;
await loadSectors(true);
}
async function loadMore() {
if (!hasMore || loading || loadingMore) return;
page += 1;
await loadSectors(false);
}
async function loadSectors(isInitial: boolean) {
if (isInitial) {
loading = true;
} else {
loadingMore = true;
}
try {
const response = await sectorsApi.list(page, pageSize);
if (response.error) {
toast.error(`Error: ${response.error}`);
hasMore = false;
return;
}
let newItems = response.data?.items || [];
totalItems = response.data?.total || 0;
if (searchTerm) {
newItems = newItems.filter(
(item) =>
item.description.toLowerCase().includes(searchTerm.toLowerCase()) ||
item.key.toLowerCase().includes(searchTerm.toLowerCase())
);
}
if (isInitial) {
items = newItems;
} else {
items = [...items, ...newItems];
}
hasMore = items.length < totalItems && newItems.length > 0;
} catch (e: any) {
console.error('Error loading sectors:', e);
toast.error('Error al conectar con el servidor');
hasMore = false;
} finally {
loading = false;
loadingMore = false;
}
}
function handleSelect(item: Sector) {
if (onSelect) onSelect(item);
open = false;
}
</script>
<Dialog.Root bind:open>
<Dialog.Content class="flex max-h-[90vh] flex-col sm:max-w-[800px]">
<Dialog.Header>
<Dialog.Title>Seleccionar Sector PROSEC</Dialog.Title>
<Dialog.Description>
Seleccione el sector del catálogo. Escrolea para ver más.
</Dialog.Description>
</Dialog.Header>
<div class="relative my-2 w-full">
<Search class="absolute top-2.5 left-2.5 h-4 w-4 text-muted-foreground" />
<Input
type="search"
placeholder="Filtrar por clave o descripción..."
class="pl-9"
bind:value={searchTerm}
/>
</div>
<div class="min-h-[300px] flex-1 overflow-y-auto rounded-md border">
{#if loading && items.length === 0}
<div class="flex h-48 flex-col items-center justify-center gap-2 text-muted-foreground">
<Loader2 class="h-8 w-8 animate-spin text-primary" />
<p>Cargando catálogo...</p>
</div>
{:else if items.length === 0}
<div class="flex h-48 flex-col items-center justify-center text-muted-foreground">
<p>No se encontraron sectores.</p>
</div>
{:else}
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head class="w-[100px]">Clave</Table.Head>
<Table.Head>Descripción</Table.Head>
<Table.Head class="w-[100px]">Autorizado</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each items as item}
<Table.Row
class="cursor-pointer transition-colors hover:bg-accent/50"
onclick={() => handleSelect(item)}
>
<Table.Cell>
<div class="flex items-center gap-1">
<Factory class="h-3 w-3 text-orange-500" />
<span class="font-mono text-xs font-bold">
{item.key}
</span>
</div>
</Table.Cell>
<Table.Cell class="text-sm font-medium">
{item.description}
</Table.Cell>
<Table.Cell>
<span
class="rounded-full px-2 py-0.5 text-xs {item.authorized
? 'bg-green-100 text-green-700'
: 'bg-red-100 text-red-700'}"
>
{item.authorized ? 'Sí' : 'No'}
</span>
</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
<div bind:this={bottomSentinel} class="flex h-10 items-center justify-center">
{#if loadingMore}
<Loader2 class="h-5 w-5 animate-spin text-muted-foreground" />
{/if}
</div>
{/if}
</div>
<Dialog.Footer>
<div class="mr-auto self-center text-xs text-muted-foreground">
{items.length} de {totalItems} registros
</div>
<Button variant="outline" onclick={() => (open = false)}>Cancelar</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>

View File

@@ -0,0 +1,224 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import * as Dialog from '$lib/components/ui/dialog';
import * as Table from '$lib/components/ui/table';
import { Search, Loader2, MapPin } from 'lucide-svelte';
import { statesApi, type State } from '$lib/api/dashboard/reference_data/states';
import { toast } from 'svelte-sonner';
// --- PROPS ---
let {
open = $bindable(false),
onSelect
}: {
open: boolean;
onSelect: (item: State) => void;
} = $props();
// --- ESTADO ---
let items = $state<State[]>([]);
let loading = $state(false);
let loadingMore = $state(false);
let searchTerm = $state('');
let previousSearchTerm = '';
let page = $state(1);
let pageSize = 50;
let hasMore = $state(true);
let totalItems = $state(0);
let observer: IntersectionObserver | null = null;
let bottomSentinel: HTMLElement | null = $state(null);
let searchTimeout: any;
let isInitialized = false;
// Cargar datos iniciales al abrir
$effect(() => {
if (open && !isInitialized) {
isInitialized = true;
previousSearchTerm = searchTerm;
resetAndLoad();
} else if (!open) {
isInitialized = false;
}
});
// Manejar búsqueda con debouncing
$effect(() => {
const term = searchTerm;
if (isInitialized && term !== previousSearchTerm) {
if (searchTimeout) clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
previousSearchTerm = term;
resetAndLoad();
}, 500);
}
});
// Configurar IntersectionObserver para infinite scroll
$effect(() => {
if (bottomSentinel && hasMore && !loading && !loadingMore && open) {
if (observer) observer.disconnect();
observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && hasMore && !loading && !loadingMore) {
loadMore();
}
},
{ threshold: 0.1 }
);
observer.observe(bottomSentinel);
}
return () => {
if (observer) observer.disconnect();
};
});
async function resetAndLoad() {
page = 1;
items = [];
hasMore = true;
await loadStates(true);
}
async function loadMore() {
if (!hasMore || loading || loadingMore) return;
page += 1;
await loadStates(false);
}
async function loadStates(isInitial: boolean) {
if (isInitial) {
loading = true;
} else {
loadingMore = true;
}
try {
// Note: statesApi.list takes page, pageSize, and searchTerm?
// Wait, let me check statesApi.list signature again.
// It only takes page and pageSize! I need to check if it supports search.
const response = await statesApi.list(page, pageSize);
if (response.error) {
toast.error(`Error: ${response.error}`);
hasMore = false;
return;
}
// Local filtering if search term exists (temporary workaround if API doesn't support it)
let newItems = response.data?.items || [];
totalItems = response.data?.total || 0;
if (searchTerm) {
newItems = newItems.filter(
(item) =>
item.description.toLowerCase().includes(searchTerm.toLowerCase()) ||
item.m3_key.toLowerCase().includes(searchTerm.toLowerCase())
);
}
if (isInitial) {
items = newItems;
} else {
items = [...items, ...newItems];
}
hasMore = items.length < totalItems && newItems.length > 0;
} catch (e: any) {
console.error('Error loading states:', e);
toast.error('Error al conectar con el servidor');
hasMore = false;
} finally {
loading = false;
loadingMore = false;
}
}
function handleSelect(item: State) {
if (onSelect) onSelect(item);
open = false;
}
</script>
<Dialog.Root bind:open>
<Dialog.Content class="flex max-h-[90vh] flex-col sm:max-w-[800px]">
<Dialog.Header>
<Dialog.Title>Seleccionar Estado</Dialog.Title>
<Dialog.Description>
Seleccione el estado del catálogo. Escrolea para ver más.
</Dialog.Description>
</Dialog.Header>
<div class="relative my-2 w-full">
<Search class="absolute top-2.5 left-2.5 h-4 w-4 text-muted-foreground" />
<Input
type="search"
placeholder="Filtrar por clave o descripción..."
class="pl-9"
bind:value={searchTerm}
/>
</div>
<div class="min-h-[300px] flex-1 overflow-y-auto rounded-md border">
{#if loading && items.length === 0}
<div class="flex h-48 flex-col items-center justify-center gap-2 text-muted-foreground">
<Loader2 class="h-8 w-8 animate-spin text-primary" />
<p>Cargando catálogo...</p>
</div>
{:else if items.length === 0}
<div class="flex h-48 flex-col items-center justify-center text-muted-foreground">
<p>No se encontraron estados.</p>
</div>
{:else}
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head class="w-[100px]">Clave M3</Table.Head>
<Table.Head>Descripción</Table.Head>
<Table.Head class="w-[80px]">MEX</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each items as item}
<Table.Row
class="cursor-pointer transition-colors hover:bg-accent/50"
onclick={() => handleSelect(item)}
>
<Table.Cell>
<div class="flex items-center gap-1">
<MapPin class="h-3 w-3 text-red-500" />
<span class="font-mono text-xs font-bold">
{item.m3_key}
</span>
</div>
</Table.Cell>
<Table.Cell class="text-sm font-medium">
{item.description}
</Table.Cell>
<Table.Cell class="font-mono text-xs">
{item.mex_key || '-'}
</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
<div bind:this={bottomSentinel} class="flex h-10 items-center justify-center">
{#if loadingMore}
<Loader2 class="h-5 w-5 animate-spin text-muted-foreground" />
{/if}
</div>
{/if}
</div>
<Dialog.Footer>
<div class="mr-auto self-center text-xs text-muted-foreground">
{items.length} de {totalItems} registros
</div>
<Button variant="outline" onclick={() => (open = false)}>Cancelar</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>