Merge development, resolviendo utilidades combinadas y actualizando ClassDialog
This commit is contained in:
@@ -48,27 +48,30 @@ class ClassService:
|
||||
query = query.filter(Class.company_id == company_id)
|
||||
|
||||
if filters:
|
||||
if filters.get("q"):
|
||||
search = f"%{filters['q']}%"
|
||||
# Búsqueda libre: OR en clave y descripciones (selectores / listados)
|
||||
search_raw = filters.get("search") or filters.get("q")
|
||||
if search_raw and str(search_raw).strip():
|
||||
pattern = f"%{str(search_raw).strip()}%"
|
||||
query = query.filter(
|
||||
or_(
|
||||
Class.class_code.ilike(search),
|
||||
Class.description_es.ilike(search),
|
||||
Class.description_en.ilike(search)
|
||||
Class.class_code.ilike(pattern),
|
||||
Class.description_es.ilike(pattern),
|
||||
Class.description_en.ilike(pattern),
|
||||
)
|
||||
)
|
||||
if filters.get("class_code"):
|
||||
query = query.filter(
|
||||
Class.class_code.ilike(f"%{filters['class_code']}%")
|
||||
)
|
||||
if filters.get("description"):
|
||||
description_pattern = f"%{filters['description']}%"
|
||||
query = query.filter(
|
||||
or_(
|
||||
Class.description_es.ilike(description_pattern),
|
||||
Class.description_en.ilike(description_pattern),
|
||||
else:
|
||||
if filters.get("class_code"):
|
||||
query = query.filter(
|
||||
Class.class_code.ilike(f"%{filters['class_code']}%")
|
||||
)
|
||||
if filters.get("description"):
|
||||
description_pattern = f"%{filters['description']}%"
|
||||
query = query.filter(
|
||||
or_(
|
||||
Class.description_es.ilike(description_pattern),
|
||||
Class.description_en.ilike(description_pattern),
|
||||
)
|
||||
)
|
||||
)
|
||||
if filters.get("material_key"):
|
||||
query = query.filter(
|
||||
Class.material_key.ilike(f"%{filters['material_key']}%")
|
||||
|
||||
@@ -58,7 +58,9 @@ export interface A76ClassListParams {
|
||||
page_size?: number;
|
||||
class_code?: string;
|
||||
description?: string;
|
||||
q?: string; // Agregado por si usas búsqueda general
|
||||
/** Búsqueda OR en class_code, description_es y description_en (backend ClassService) */
|
||||
search?: string;
|
||||
q?: string;
|
||||
sort_by?: string;
|
||||
sort_order?: 'asc' | 'desc';
|
||||
}
|
||||
|
||||
@@ -1,156 +1,220 @@
|
||||
<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 { Search, Loader2, Tag, Ruler } from "lucide-svelte";
|
||||
import { classesApi, type A76Class } from "$lib/api/dashboard/a76/classes"; // Ajusta ruta
|
||||
import { companyStore } from "$lib/stores/company.svelte";
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
import { Search, Loader2, Tag, Ruler } from 'lucide-svelte';
|
||||
import { classesApi, type A76Class } from '$lib/api/dashboard/a76/classes';
|
||||
import { companyStore } from '$lib/stores/company.svelte';
|
||||
|
||||
// --- PROPS ---
|
||||
let {
|
||||
open = $bindable(false),
|
||||
onSelect
|
||||
}: {
|
||||
open: boolean,
|
||||
onSelect: (item: A76Class) => void
|
||||
} = $props();
|
||||
let {
|
||||
open = $bindable(false),
|
||||
onSelect
|
||||
}: {
|
||||
open: boolean;
|
||||
onSelect: (item: A76Class) => void;
|
||||
} = $props();
|
||||
|
||||
// --- ESTADO ---
|
||||
let items = $state<A76Class[]>([]);
|
||||
let loading = $state(false);
|
||||
let searchTerm = $state("");
|
||||
let loaded = $state(false);
|
||||
let items = $state<A76Class[]>([]);
|
||||
let total = $state(0);
|
||||
let currentPage = $state(1);
|
||||
let loading = $state(false);
|
||||
let loadingMore = $state(false);
|
||||
let searchTerm = $state('');
|
||||
|
||||
// Filtro local: Busca por Clave (class_code) o Descripción (description_es)
|
||||
let filteredItems = $derived(
|
||||
items.filter(i =>
|
||||
(i.class_code || "").toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
(i.description_es || "").toLowerCase().includes(searchTerm.toLowerCase())
|
||||
)
|
||||
);
|
||||
const PAGE_SIZE = 100;
|
||||
|
||||
// Cargar datos al abrir
|
||||
$effect(() => {
|
||||
if (open && !loaded && companyStore.activeCompany?.id) {
|
||||
loadClasses();
|
||||
}
|
||||
});
|
||||
const hasMore = $derived(items.length < total && total > 0);
|
||||
|
||||
async function loadClasses() {
|
||||
if (!companyStore.activeCompany?.id) return;
|
||||
|
||||
loading = true;
|
||||
try {
|
||||
|
||||
const res = await classesApi.list({
|
||||
company_id: companyStore.activeCompany.id,
|
||||
page: 1,
|
||||
page_size: 100
|
||||
});
|
||||
|
||||
|
||||
const data = (res as any).data || res;
|
||||
|
||||
|
||||
const list = data.items || data.classes || [];
|
||||
|
||||
if (Array.isArray(list)) {
|
||||
items = list;
|
||||
loaded = true;
|
||||
} else {
|
||||
console.warn("No encontré lista de clases en la respuesta:", data);
|
||||
}
|
||||
async function loadPage(trimmed: string, page: number, append: boolean) {
|
||||
const companyId = companyStore.activeCompany?.id;
|
||||
if (!companyId) return;
|
||||
|
||||
} catch (e) {
|
||||
console.error("Error cargando clases:", e);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
if (append) {
|
||||
if (loadingMore || loading || !hasMore) return;
|
||||
} else if (loading) {
|
||||
return;
|
||||
}
|
||||
|
||||
function handleSelect(item: A76Class) {
|
||||
if (onSelect) onSelect(item);
|
||||
open = false;
|
||||
}
|
||||
if (page === 1) {
|
||||
loading = true;
|
||||
} else {
|
||||
loadingMore = true;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await classesApi.list({
|
||||
company_id: companyId,
|
||||
page,
|
||||
page_size: PAGE_SIZE,
|
||||
...(trimmed ? { search: trimmed } : {})
|
||||
});
|
||||
|
||||
const data = (res as { data?: { items?: A76Class[]; total?: number } }).data ?? res;
|
||||
const raw = (data as { items?: A76Class[] }).items ?? [];
|
||||
const newItems = Array.isArray(raw) ? raw : [];
|
||||
|
||||
if (append && newItems.length === 0) {
|
||||
total = items.length;
|
||||
return;
|
||||
}
|
||||
|
||||
const t =
|
||||
typeof (data as { total?: number }).total === 'number'
|
||||
? (data as { total: number }).total
|
||||
: append
|
||||
? items.length + newItems.length
|
||||
: newItems.length;
|
||||
|
||||
if (append) {
|
||||
items = [...items, ...newItems];
|
||||
} else {
|
||||
items = newItems;
|
||||
}
|
||||
total = t;
|
||||
currentPage = page;
|
||||
} catch (e) {
|
||||
console.error('Error cargando clases:', e);
|
||||
if (!append) {
|
||||
items = [];
|
||||
total = 0;
|
||||
}
|
||||
} finally {
|
||||
loading = false;
|
||||
loadingMore = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleScroll(e: Event) {
|
||||
const target = e.currentTarget as HTMLDivElement;
|
||||
const threshold = 80;
|
||||
if (target.scrollHeight - target.scrollTop - target.clientHeight > threshold) return;
|
||||
if (!hasMore || loading || loadingMore) return;
|
||||
const term = searchTerm.trim();
|
||||
loadPage(term, currentPage + 1, true);
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (!open) {
|
||||
searchTerm = '';
|
||||
items = [];
|
||||
total = 0;
|
||||
currentPage = 1;
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!open) return;
|
||||
const companyId = companyStore.activeCompany?.id;
|
||||
if (!companyId) return;
|
||||
|
||||
const term = searchTerm.trim();
|
||||
const delayMs = term === '' ? 0 : 300;
|
||||
const handle = setTimeout(() => {
|
||||
loadPage(term, 1, false);
|
||||
}, delayMs);
|
||||
return () => clearTimeout(handle);
|
||||
});
|
||||
|
||||
function handleSelect(item: A76Class) {
|
||||
if (onSelect) onSelect(item);
|
||||
open = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<Dialog.Root bind:open={open}>
|
||||
<Dialog.Content class="sm:max-w-[700px] max-h-[80vh] flex flex-col z-[300]">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>Seleccionar Clase (Anexo 24)</Dialog.Title>
|
||||
<Dialog.Description>
|
||||
Seleccione la clasificación del material.
|
||||
</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
<Dialog.Content class="sm:max-w-[700px] max-h-[80vh] flex flex-col z-[300]">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>Seleccionar Clase (Anexo 24)</Dialog.Title>
|
||||
<Dialog.Description>
|
||||
Seleccione la clasificación del material. Desplace hacia abajo para cargar más resultados.
|
||||
</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
|
||||
<div class="relative w-full my-2">
|
||||
<Search class="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
type="search"
|
||||
placeholder="Buscar por Clave o Descripción..."
|
||||
class="pl-9"
|
||||
bind:value={searchTerm}
|
||||
/>
|
||||
</div>
|
||||
<div class="relative w-full my-2">
|
||||
<Search class="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
type="search"
|
||||
placeholder="Buscar por Clave o Descripción..."
|
||||
class="pl-9"
|
||||
bind:value={searchTerm}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-y-auto border rounded-md min-h-[300px]">
|
||||
{#if loading}
|
||||
<div class="flex flex-col items-center justify-center h-48 gap-2 text-muted-foreground">
|
||||
<Loader2 class="h-8 w-8 animate-spin text-primary" />
|
||||
<p>Cargando catálogo...</p>
|
||||
</div>
|
||||
{:else if filteredItems.length === 0}
|
||||
<div class="flex flex-col items-center justify-center h-48 text-muted-foreground">
|
||||
<p>No se encontraron clases.</p>
|
||||
</div>
|
||||
{:else}
|
||||
<table class="w-full text-sm">
|
||||
<thead class="bg-muted/50 sticky top-0 backdrop-blur-sm">
|
||||
<tr class="text-left border-b">
|
||||
<th class="p-3 font-medium text-muted-foreground w-[100px]">Clave</th>
|
||||
<th class="p-3 font-medium text-muted-foreground">Descripción</th>
|
||||
<th class="p-3 font-medium text-muted-foreground w-[80px]">UM</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each filteredItems as item}
|
||||
<tr
|
||||
class="border-b hover:bg-accent/50 transition-colors cursor-pointer"
|
||||
onclick={() => handleSelect(item)}
|
||||
>
|
||||
<td class="p-3">
|
||||
<span class="font-mono font-bold text-primary bg-primary/10 px-2 py-1 rounded text-xs">
|
||||
{item.class_code}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
<td class="p-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<Tag class="h-3 w-3 text-muted-foreground shrink-0" />
|
||||
<span class="truncate max-w-[300px]" title={item.description_es || ''}>
|
||||
{item.description_es || 'Sin descripción'}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<div
|
||||
class="flex-1 overflow-y-auto border rounded-md min-h-[300px]"
|
||||
onscroll={handleScroll}
|
||||
>
|
||||
{#if loading}
|
||||
<div class="flex flex-col items-center justify-center h-48 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 flex-col items-center justify-center h-48 text-muted-foreground">
|
||||
<p>No se encontraron clases.</p>
|
||||
</div>
|
||||
{:else}
|
||||
<table class="w-full text-sm">
|
||||
<thead class="bg-muted/50 sticky top-0 backdrop-blur-sm">
|
||||
<tr class="text-left border-b">
|
||||
<th class="p-3 font-medium text-muted-foreground w-[100px]">Clave</th>
|
||||
<th class="p-3 font-medium text-muted-foreground">Descripción</th>
|
||||
<th class="p-3 font-medium text-muted-foreground w-[80px]">UM</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each items as item}
|
||||
<tr
|
||||
class="border-b hover:bg-accent/50 transition-colors cursor-pointer"
|
||||
onclick={() => handleSelect(item)}
|
||||
>
|
||||
<td class="p-3">
|
||||
<span
|
||||
class="font-mono font-bold text-primary bg-primary/10 px-2 py-1 rounded text-xs"
|
||||
>
|
||||
{item.class_code}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
<td class="p-3">
|
||||
<div class="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<Ruler class="h-3 w-3" />
|
||||
{item.unit_of_measure || '-'}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
{/if}
|
||||
</div>
|
||||
<td class="p-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<Tag class="h-3 w-3 text-muted-foreground shrink-0" />
|
||||
<span
|
||||
class="truncate max-w-[300px]"
|
||||
title={item.description_es || item.description_en || ''}
|
||||
>
|
||||
{item.description_es || item.description_en || 'Sin descripción'}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<Dialog.Footer>
|
||||
<div class="text-xs text-muted-foreground self-center mr-auto">
|
||||
{filteredItems.length} registros encontrados
|
||||
</div>
|
||||
<Button variant="outline" onclick={() => open = false}>Cancelar</Button>
|
||||
</Dialog.Footer>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
<td class="p-3">
|
||||
<div class="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<Ruler class="h-3 w-3" />
|
||||
{item.unit_of_measure || '-'}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
{#if loadingMore}
|
||||
<div class="flex justify-center py-3 text-muted-foreground">
|
||||
<Loader2 class="h-6 w-6 animate-spin" />
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<Dialog.Footer>
|
||||
<div class="text-xs text-muted-foreground self-center mr-auto">
|
||||
{#if loading}
|
||||
…
|
||||
{:else}
|
||||
Mostrando {items.length} de {total} registros
|
||||
{/if}
|
||||
</div>
|
||||
<Button variant="outline" onclick={() => (open = false)}>Cancelar</Button>
|
||||
</Dialog.Footer>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { companyStore } from '$lib/stores/company.svelte';
|
||||
import { onMount } from 'svelte';
|
||||
import { classesApi } from '$lib/api/dashboard/a76/classes';
|
||||
|
||||
let {
|
||||
open = $bindable(false),
|
||||
@@ -22,9 +23,9 @@
|
||||
let isLoadingMore = $state(false);
|
||||
let classes = $state<any[]>([]);
|
||||
let currentPage = $state(1);
|
||||
let hasMore = $state(true);
|
||||
let totalItems = $state(0);
|
||||
const itemsPerPage = 25;
|
||||
const itemsPerPage = 50;
|
||||
const hasMore = $derived(classes.length < totalItems && totalItems > 0);
|
||||
|
||||
async function fetchClasses(page: number = 1, search: string = '') {
|
||||
const activeCompanyId = companyStore?.activeCompany?.id;
|
||||
@@ -34,26 +35,17 @@
|
||||
else isLoadingMore = true;
|
||||
|
||||
try {
|
||||
// Construct query params
|
||||
const params = new URLSearchParams({
|
||||
company_id: activeCompanyId.toString(),
|
||||
page: page.toString(),
|
||||
page_size: itemsPerPage.toString(),
|
||||
sort_by: 'class_code',
|
||||
sort_order: 'asc'
|
||||
const res: any = await classesApi.list({
|
||||
company_id: activeCompanyId,
|
||||
page,
|
||||
page_size: itemsPerPage,
|
||||
...(search ? { search } : {})
|
||||
});
|
||||
|
||||
if (search) {
|
||||
// Most TenantCRUDRoutes support 'q' for general search or field-specific filters
|
||||
params.append('q', search);
|
||||
}
|
||||
|
||||
const response = await fetch(`/api-sveltekit/classes?${params.toString()}`);
|
||||
|
||||
if (!response.ok) throw new Error('Error al buscar clases');
|
||||
|
||||
const data = await response.json();
|
||||
const newItems = data.items || [];
|
||||
|
||||
// Manejar diferentes formatos de respuesta
|
||||
const data = res.data ?? res;
|
||||
const newItems = Array.isArray(data.items) ? data.items : [];
|
||||
const total = data.total ?? (page === 1 ? newItems.length : classes.length + newItems.length);
|
||||
|
||||
if (page === 1) {
|
||||
classes = newItems;
|
||||
@@ -61,12 +53,15 @@
|
||||
classes = [...classes, ...newItems];
|
||||
}
|
||||
|
||||
totalItems = data.total || 0;
|
||||
hasMore = newItems.length === itemsPerPage;
|
||||
totalItems = total;
|
||||
currentPage = page;
|
||||
} catch (error) {
|
||||
console.error('Error fetching classes:', error);
|
||||
toast.error('Error al cargar clases');
|
||||
if (page === 1) {
|
||||
classes = [];
|
||||
totalItems = 0;
|
||||
}
|
||||
} finally {
|
||||
isSearching = false;
|
||||
isLoadingMore = false;
|
||||
@@ -75,9 +70,7 @@
|
||||
|
||||
// Debounce effect
|
||||
$effect(() => {
|
||||
// Accedemos a searchQuery para que el efecto dependa de él
|
||||
const query = searchQuery;
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
if (debouncedSearch !== query) {
|
||||
debouncedSearch = query;
|
||||
@@ -85,10 +78,20 @@
|
||||
fetchClasses(1, query);
|
||||
}
|
||||
}, 400);
|
||||
|
||||
return () => clearTimeout(timeout);
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!open) {
|
||||
searchQuery = '';
|
||||
classes = [];
|
||||
totalItems = 0;
|
||||
currentPage = 1;
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
|
||||
function handleSelect(classItem: any) {
|
||||
if (onSelect) onSelect(classItem);
|
||||
open = false;
|
||||
@@ -158,7 +161,7 @@
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{#each classes as classItem (classItem.id)}
|
||||
{#each classes as classItem (classItem.id || classItem.class_code)}
|
||||
<Table.Row
|
||||
class="group cursor-pointer border-zinc-100 dark:border-zinc-800/50 hover:bg-blue-50/50 dark:hover:bg-blue-900/10 transition-colors"
|
||||
onclick={() => handleSelect(classItem)}
|
||||
@@ -197,7 +200,6 @@
|
||||
</Table.Row>
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
<!-- Infinite Scroll Trigger -->
|
||||
{#if hasMore}
|
||||
<Table.Row class="border-none hover:bg-transparent">
|
||||
|
||||
Reference in New Issue
Block a user