Se le agrego multiselect a las partes

This commit is contained in:
2026-05-07 16:49:41 -05:00
parent 04d65ef738
commit 246f2d33f7
2 changed files with 236 additions and 147 deletions

View File

@@ -1,11 +1,8 @@
<script lang="ts" generics="TData, TValue">
import { onMount } from 'svelte';
import {
type ColumnDef,
getCoreRowModel
} from "@tanstack/table-core";
import { createSvelteTable, FlexRender } from "$lib/components/ui/data-table/index.js";
import * as Table from "$lib/components/ui/table/index.js";
import { type ColumnDef, getCoreRowModel } from '@tanstack/table-core';
import { createSvelteTable, FlexRender } from '$lib/components/ui/data-table/index.js';
import * as Table from '$lib/components/ui/table/index.js';
type DataTableProps<TData, TValue> = {
columns: ColumnDef<TData, TValue>[];
@@ -13,10 +10,10 @@
loading: boolean;
hasMore: boolean;
loadMore: () => void;
sorting?: import("@tanstack/table-core").SortingState;
onSortingChange?: (sorting: import("@tanstack/table-core").SortingState) => void;
rowSelection?: import("@tanstack/table-core").RowSelectionState;
onRowSelectionChange?: (rowSelection: import("@tanstack/table-core").RowSelectionState) => void;
sorting?: import('@tanstack/table-core').SortingState;
onSortingChange?: (sorting: import('@tanstack/table-core').SortingState) => void;
rowSelection?: import('@tanstack/table-core').RowSelectionState;
onRowSelectionChange?: (rowSelection: import('@tanstack/table-core').RowSelectionState) => void;
onRowClick?: (row: TData) => void;
onRowDoubleClick?: (row: TData) => void;
};
@@ -29,7 +26,7 @@
loadMore,
sorting = [],
onSortingChange,
rowSelection = {},
rowSelection = $bindable({}),
onRowSelectionChange,
onRowClick,
onRowDoubleClick
@@ -41,6 +38,7 @@
},
columns,
getCoreRowModel: getCoreRowModel(),
getRowId: (row: any) => row.id?.toString(),
state: {
get sorting() {
return sorting;
@@ -56,18 +54,20 @@
}
},
onRowSelectionChange: (updater) => {
const nextRowSelection = typeof updater === 'function' ? updater(rowSelection) : updater;
rowSelection = nextRowSelection;
if (onRowSelectionChange) {
const nextRowSelection = typeof updater === 'function' ? updater(rowSelection) : updater;
onRowSelectionChange(nextRowSelection);
}
},
manualSorting: true
manualSorting: true,
enableRowSelection: true,
enableMultiRowSelection: true // Multiselect activado
});
let scrollContainer = $state<HTMLDivElement>();
let loadingTrigger = $state<HTMLDivElement>();
// Intersection Observer para detectar cuando el usuario llega al final
onMount(() => {
const observer = new IntersectionObserver(
(entries) => {
@@ -76,10 +76,7 @@
loadMore();
}
},
{
root: scrollContainer,
threshold: 0.1
}
{ root: scrollContainer, threshold: 0.1 }
);
if (loadingTrigger) {
@@ -92,20 +89,20 @@
});
</script>
<div class="w-full">
<div class="w-full h-full flex flex-col overflow-hidden">
<div
class="rounded-md border min-h-[400px] max-h-[calc(100vh-310px)] overflow-y-auto"
class="rounded-md border flex-1 overflow-auto bg-white dark:bg-black"
bind:this={scrollContainer}
>
<Table.Root>
<Table.Header class="bg-background">
<Table.Root class="w-full">
<Table.Header class="bg-background sticky top-0 z-10 shadow-sm">
{#each table.getHeaderGroups() as headerGroup (headerGroup.id)}
<Table.Row>
{#each headerGroup.headers as header (header.id)}
<Table.Head class="p-0">
<Table.Head class="whitespace-nowrap p-0">
{#if !header.isPlaceholder}
<button
class="group flex h-full w-full items-center gap-2 px-4 py-2 text-left hover:bg-muted/50"
class="group flex h-full w-full items-center gap-2 px-3 py-2 text-left hover:bg-muted/50"
onclick={header.column.getToggleSortingHandler()}
disabled={!header.column.getCanSort()}
>
@@ -169,43 +166,53 @@
</Table.Header>
<Table.Body>
{#each table.getRowModel().rows as row (row.id)}
<Table.Row
data-state={row.getIsSelected() && "selected"}
onclick={() => onRowClick?.(row.original)}
<Table.Row
data-state={row.getIsSelected() && 'selected'}
onclick={(e) => {
row.getToggleSelectedHandler()(e);
onRowClick?.(row.original);
}}
ondblclick={() => onRowDoubleClick?.(row.original)}
class={(onRowClick || onRowDoubleClick) ? "cursor-pointer" : ""}
class="cursor-pointer transition-colors {row.getIsSelected()
? 'bg-blue-50/50 dark:bg-blue-900/20'
: 'hover:bg-muted/50'}"
>
{#each row.getVisibleCells() as cell (cell.id)}
<Table.Cell>
<FlexRender
content={cell.column.columnDef.cell}
context={cell.getContext()}
/>
<Table.Cell class="whitespace-nowrap px-3 py-2">
<FlexRender content={cell.column.columnDef.cell} context={cell.getContext()} />
</Table.Cell>
{/each}
</Table.Row>
{:else}
<Table.Row>
<Table.Cell colspan={columns.length} class="h-24 text-center">
No hay resultados.
{#if loading}
<div class="flex items-center justify-center gap-2">
<div
class="h-4 w-4 animate-spin rounded-full border-2 border-primary border-t-transparent"
></div>
<p class="text-sm text-muted-foreground">Cargando...</p>
</div>
{:else}
No hay resultados.
{/if}
</Table.Cell>
</Table.Row>
{/each}
<!-- Loading Trigger - Se activa cuando es visible -->
{#if hasMore}
<Table.Row>
<Table.Cell colspan={columns.length} class="h-20 text-center">
<div bind:this={loadingTrigger}>
{#if loading}
<div class="flex items-center justify-center gap-2">
<div class="h-4 w-4 animate-spin rounded-full border-2 border-primary border-t-transparent"></div>
<div
class="h-4 w-4 animate-spin rounded-full border-2 border-primary border-t-transparent"
></div>
<span class="text-muted-foreground text-sm">Cargando más...</span>
</div>
{:else}
<div class="text-muted-foreground text-sm">
Desplázate para cargar más
</div>
<div class="text-muted-foreground text-sm">Desplázate para cargar más</div>
{/if}
</div>
</Table.Cell>

View File

@@ -2,7 +2,8 @@
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import { Plus, RefreshCw, Package } from 'lucide-svelte';
import * as Dialog from '$lib/components/ui/dialog';
import { Plus, RefreshCw, Package, Folder, CheckSquare } from 'lucide-svelte';
import { toast } from 'svelte-sonner';
import { partsApi, type Part } from '$lib/api/dashboard/a76/parts';
import { companyStore } from '$lib/stores/company.svelte';
@@ -21,9 +22,7 @@
} from '$lib/permissions/goods-parts-permissions';
import { formatMexTariffDigitsForDisplay } from '$lib/utils/mexican-tariff-fraction';
// Estado de la lista de partes
let parts = $state<Part[]>([]);
let selectedPart = $state<Part | null>(null);
let isLoading = $state(false);
let searchPartNumber = $state('');
let searchDescription = $state('');
@@ -31,9 +30,11 @@
let sorting = $state<import('@tanstack/table-core').SortingState>([
{ id: 'updated_at', desc: true }
]);
let rowSelection = $state<import('@tanstack/table-core').RowSelectionState>({});
let error = $state<string | null>(null);
let status = $state<number>(200);
let showDeleteDialog = $state(false);
// Permisos
const canView = $derived(canViewGoodsParts($currentUser));
@@ -65,7 +66,13 @@
})
);
// Efecto unificado para evitar doble llamada a la API
// MULTI-SELECT LÓGICA
const selectedPartIds = $derived(Object.keys(rowSelection).filter((id) => rowSelection[id]));
const selectedParts = $derived(
filteredParts.filter((p) => selectedPartIds.includes(p.id.toString()))
);
const activePart = $derived(selectedParts.length === 1 ? selectedParts[0] : null);
$effect(() => {
const companyId = companyStore.activeCompany?.id;
if (companyId && sorting) {
@@ -73,7 +80,6 @@
}
});
// Shortcuts con validación de permisos
useShortcuts(
'Goods List',
obtenerAtajosListaMercancias({
@@ -90,16 +96,16 @@
toast.error('No tienes permiso para editar partes');
return;
}
if (selectedPart?.id) goto(`/dashboard/goods/parts/edit/${selectedPart.id}`);
else toast.info('Seleccione una parte para editar');
if (activePart?.id) goto(`/dashboard/goods/parts/edit/${activePart.id}`);
else toast.info('Seleccione una sola parte para editar');
},
eliminar: () => {
if (!canDelete) {
toast.error('No tienes permiso para eliminar partes');
return;
}
if (selectedPart?.id) handleDelete();
else toast.info('Seleccione una parte para eliminar');
if (selectedParts.length > 0) handleDelete();
else toast.info('Seleccione al menos una parte para eliminar');
}
})
);
@@ -135,41 +141,39 @@
}
}
function selectPart(part: Part) {
if (!canView) return;
selectedPart = part;
}
async function handleRefresh() {
if (!canView) return;
await loadData();
toast.success('Partes actualizadas');
}
async function handleDelete() {
function handleDelete() {
if (!canDelete) return;
if (!selectedPart) {
toast.error('Selecciona una parte para borrar');
if (selectedParts.length === 0) {
toast.error('Selecciona al menos una parte para borrar');
return;
}
showDeleteDialog = true;
}
const confirmed = window.confirm(
`¿Estás seguro de que deseas eliminar la parte ${selectedPart.part_number}? Esta acción no se puede deshacer.`
);
if (!confirmed) return;
async function confirmDelete() {
if (selectedParts.length === 0) return;
isLoading = true;
try {
const companyId = companyStore.activeCompany?.id;
if (!companyId) return;
await partsApi.delete(selectedPart.id, companyId);
toast.success('Parte eliminada exitosamente');
selectedPart = null;
// Borrado en lote usando Promise.all
await Promise.all(selectedParts.map((part) => partsApi.delete(part.id, companyId)));
toast.success(`${selectedParts.length} parte(s) eliminada(s) exitosamente`);
rowSelection = {}; // Limpiamos la selección
await loadData();
showDeleteDialog = false;
} catch (e) {
console.error('Error al eliminar:', e);
toast.error('Error al eliminar la parte');
toast.error('Error al eliminar las partes');
} finally {
isLoading = false;
}
@@ -177,9 +181,9 @@
</script>
<div
class="flex h-[calc(100svh-4rem)] flex-col gap-6 overflow-hidden p-6 group-has-data-[collapsible=icon]/sidebar-wrapper:h-[calc(100svh-3rem)]"
class="flex flex-col gap-6 p-6 h-[calc(100svh-4rem)] group-has-data-[collapsible=icon]/sidebar-wrapper:h-[calc(100svh-3rem)] overflow-hidden"
>
<div class="flex flex-none items-center justify-between">
<div class="flex-none flex items-center justify-between">
<div class="space-y-1">
<h1 class="text-2xl font-bold tracking-tight">Catálogo de Partes</h1>
<p class="text-muted-foreground">
@@ -216,6 +220,7 @@
{/if}
<div class="flex min-h-0 flex-1 gap-4 overflow-hidden">
<!-- Panel izquierdo: Grid/Tabla de partes -->
<div class="flex min-h-0 flex-1 flex-col gap-4 overflow-hidden">
<div class="rounded-md border bg-background">
<div class="space-y-4 p-4">
@@ -255,7 +260,9 @@
<h2 class="text-sm font-semibold">Listado de Partes</h2>
<div class="flex items-center gap-2">
<span class="text-xs text-muted-foreground">
Mostrando {filteredParts.length} registros
Mostrando {filteredParts.length} registros {selectedParts.length > 0
? `(${selectedParts.length} seleccionados)`
: ''}
</span>
<Button variant="outline" size="sm" class="h-9" onclick={handleRefresh}>
<RefreshCw class="mr-2 h-4 w-4" />
@@ -264,67 +271,96 @@
</div>
</div>
<div class="flex-1 overflow-hidden p-0">
<DataTable
data={filteredParts}
{columns}
loading={isLoading}
hasMore={false}
loadMore={() => {}}
{sorting}
onSortingChange={(newSorting) => (sorting = newSorting)}
onRowClick={selectPart}
onRowDoubleClick={(part) => {
if (!canEdit) {
toast.error('No tienes permiso para editar partes');
return;
}
goto(`/dashboard/goods/parts/edit/${part.id}`);
}}
/>
<div class="flex min-h-0 flex-1 flex-col overflow-hidden p-0">
<div class="min-h-0 flex-1 overflow-hidden">
<DataTable
data={filteredParts}
{columns}
loading={isLoading}
hasMore={false}
loadMore={() => {}}
{sorting}
onSortingChange={(newSorting) => (sorting = newSorting)}
bind:rowSelection
onRowDoubleClick={(part) => {
if (!canEdit) {
toast.error('No tienes permiso para editar partes');
return;
}
goto(`/dashboard/goods/parts/edit/${part.id}`);
}}
/>
</div>
</div>
</div>
</div>
<!-- Panel derecho: Detalles -->
<div
class="flex w-96 flex-none flex-col overflow-hidden rounded-xl border bg-muted/30 shadow-sm"
>
<div class="border-b p-4">
<p class="text-[10px] tracking-widest text-muted-foreground uppercase opacity-80">
Número de Parte
</p>
<div class="flex flex-wrap items-center gap-2">
<h2 class="font-mono text-3xl font-black tracking-tighter">
{selectedPart?.part_number || '---'}
</h2>
{#if selectedPart?.inv_data && selectedPart?.fa_data}
<span
class="rounded-full border border-purple-200 bg-purple-50 px-2 py-0.5 text-[10px] font-bold tracking-wider text-purple-700 uppercase"
>AMBOS</span
>
{:else if selectedPart?.inv_data}
<span
class="rounded-full border border-sky-200 bg-sky-50 px-2 py-0.5 text-[10px] font-bold tracking-wider text-sky-700 uppercase"
>SCAI</span
>
{:else if selectedPart?.fa_data}
<span
class="rounded-full border border-amber-200 bg-amber-50 px-2 py-0.5 text-[10px] font-bold tracking-wider text-amber-700 uppercase"
>SCAF</span
>
{/if}
{#if selectedParts.length > 1}
<!-- Vista de Multi-Selección -->
<div class="border-b p-4">
<p class="text-[10px] tracking-widest text-muted-foreground uppercase opacity-80">
Selección Múltiple
</p>
<div class="flex items-center gap-2 mt-1">
<h2 class="font-mono text-3xl font-black tracking-tighter text-primary">
{selectedParts.length}
</h2>
<span class="text-sm font-semibold text-muted-foreground">Partes seleccionadas</span>
</div>
</div>
<div
class="flex flex-1 flex-col items-center justify-center space-y-4 p-6 bg-card text-center text-muted-foreground"
>
<CheckSquare class="h-16 w-16 opacity-20" />
<p class="text-sm">
Tienes múltiples partes seleccionadas.<br />
Las acciones en lote aplicarán a todos los registros marcados.
</p>
</div>
{:else if activePart}
<!-- Vista de Selección Única -->
<div class="border-b p-4">
<p class="text-[10px] tracking-widest text-muted-foreground uppercase opacity-80">
Número de Parte
</p>
<div class="flex flex-wrap items-center gap-2">
<h2 class="font-mono text-3xl font-black tracking-tighter">
{activePart.part_number || '---'}
</h2>
{#if activePart.inv_data && activePart.fa_data}
<span
class="rounded-full border border-purple-200 bg-purple-50 px-2 py-0.5 text-[10px] font-bold tracking-wider text-purple-700 uppercase"
>
AMBOS
</span>
{:else if activePart.inv_data}
<span
class="rounded-full border border-sky-200 bg-sky-50 px-2 py-0.5 text-[10px] font-bold tracking-wider text-sky-700 uppercase"
>
SCAI
</span>
{:else if activePart.fa_data}
<span
class="rounded-full border border-amber-200 bg-amber-50 px-2 py-0.5 text-[10px] font-bold tracking-wider text-amber-700 uppercase"
>
SCAF
</span>
{/if}
</div>
</div>
</div>
<div class="flex-1 space-y-6 overflow-auto bg-card p-5">
{#if selectedPart}
<div class="flex-1 space-y-6 overflow-auto bg-card p-5">
<div class="space-y-3">
<div>
<Label class="text-[10px] font-bold text-muted-foreground uppercase"
>Descripción ES</Label
>
<p class="text-sm leading-tight font-semibold">
{selectedPart.description_spanish || 'Sin descripción'}
{activePart.description_spanish || 'Sin descripción'}
</p>
</div>
<div class="border-t border-dashed pt-2">
@@ -332,64 +368,65 @@
>Description EN</Label
>
<p class="text-sm text-muted-foreground italic">
{selectedPart.description_english || 'No translation available'}
{activePart.description_english || 'No translation available'}
</p>
</div>
</div>
<div class="grid grid-cols-1 gap-4 border-t pt-4">
<div class="grid grid-cols-2 gap-4 border-t pt-4">
<div class="space-y-1">
<Label class="text-[10px] font-bold text-muted-foreground uppercase">Clase</Label>
<span class="text-sm font-bold">{selectedPart.part_class || '-'}</span>
<div class="flex items-center gap-2">
<Folder class="h-3 w-3 text-blue-500" />
<span class="text-sm font-bold">{activePart.part_class || '-'}</span>
</div>
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div class="space-y-1">
<Label class="text-[10px] font-bold text-muted-foreground uppercase">U.M.</Label>
<span class="text-sm font-bold">{selectedPart.unit_of_measure || '-'}</span>
<span class="text-sm font-bold">{activePart.unit_of_measure || '-'}</span>
</div>
<div class="space-y-1">
<Label class="text-[10px] font-bold text-muted-foreground uppercase"
>Peso Unit.</Label
>
<span class="text-sm font-bold">{selectedPart.unit_weight || '-'}</span>
<span class="text-sm font-bold">{activePart.unit_weight || '-'}</span>
</div>
</div>
<div
class="rounded-lg border border-orange-100 bg-orange-50 p-3 dark:border-orange-900 dark:bg-orange-950/20"
>
<Label class="text-[10px] font-bold text-orange-600 uppercase dark:text-orange-400"
>Fracción Arancelaria</Label
>
<Label class="text-[10px] font-bold text-orange-600 uppercase dark:text-orange-400">
Fracción Arancelaria
</Label>
<p class="font-mono text-lg font-bold text-orange-700 dark:text-orange-300">
{formatMexTariffDigitsForDisplay(selectedPart.fraction || '') || '0000.00.00'}
{formatMexTariffDigitsForDisplay(activePart.fraction || '') || '0000.00.00'}
</p>
</div>
{#if selectedPart.unit_cost}
{#if activePart.unit_cost}
<div
class="rounded-lg border border-green-100 bg-green-50 p-3 dark:border-green-900 dark:bg-green-950/20"
>
<Label class="text-[10px] font-bold text-green-600 uppercase dark:text-green-400"
>Costo Unitario</Label
>
<Label class="text-[10px] font-bold text-green-600 uppercase dark:text-green-400">
Costo Unitario
</Label>
<p class="text-lg font-bold text-green-700 dark:text-green-300">
${selectedPart.unit_cost}
{selectedPart.currency_key || 'USD'}
${activePart.unit_cost}
{activePart.currency_key || 'USD'}
</p>
</div>
{/if}
{:else}
<div
class="flex h-full flex-col items-center justify-center text-center text-muted-foreground"
>
<Package class="mb-3 h-12 w-12 opacity-20" />
<p class="text-sm">Selecciona una parte para ver sus detalles</p>
</div>
{/if}
</div>
</div>
{:else}
<!-- Vista Vacía -->
<div
class="flex h-full flex-col items-center justify-center text-center text-muted-foreground"
>
<Package class="mb-3 h-12 w-12 opacity-20" />
<p class="text-sm">Selecciona una o más partes para ver sus detalles</p>
</div>
{/if}
</div>
</div>
{/if}
@@ -397,6 +434,7 @@
<div class="h-20"></div>
<!-- Footer fijo con botones de acción -->
{#if !isError}
<div
class="fixed right-0 bottom-0 left-0 z-[5] ml-[calc(var(--sidebar-width))] border-t bg-background/95 shadow-lg backdrop-blur group-has-data-[state=collapsed]/sidebar-wrapper:ml-[calc(var(--sidebar-width-icon))] supports-[backdrop-filter]:bg-background/80"
@@ -406,7 +444,7 @@
{#if canCreate}
<Button size="sm" href="/dashboard/goods/parts/edit/new">
<Plus class="mr-1 h-4 w-4" />
Nueva Parte
Insertar
</Button>
{/if}
@@ -414,19 +452,63 @@
<Button
variant="outline"
size="sm"
href="/dashboard/goods/parts/edit/{selectedPart?.id}"
disabled={!selectedPart}
href={activePart ? `/dashboard/goods/parts/edit/${activePart.id}` : '#'}
disabled={selectedParts.length !== 1}
>
Editar
Editar {selectedParts.length > 1 ? '(Solo 1)' : ''}
</Button>
{/if}
{#if canDelete}
<Button variant="outline" size="sm" onclick={handleDelete} disabled={!selectedPart}>
Borrar
<Button
variant="outline"
size="sm"
onclick={handleDelete}
disabled={selectedParts.length === 0}
>
Borrar {selectedParts.length > 1 ? `(${selectedParts.length})` : ''}
</Button>
{/if}
</div>
</div>
</div>
{/if}
<!-- Dialog de confirmación para borrar -->
<Dialog.Root bind:open={showDeleteDialog}>
<Dialog.Content class="max-w-md">
<Dialog.Header>
<Dialog.Title>¿Confirmar eliminación?</Dialog.Title>
</Dialog.Header>
<div class="py-4">
{#if selectedParts.length === 1}
<p class="text-sm text-muted-foreground">
¿Estás seguro que deseas eliminar la parte <strong class="text-foreground"
>{activePart?.part_number}</strong
>?
</p>
<p class="mt-2 text-sm text-muted-foreground">
{activePart?.description_spanish}
</p>
{:else}
<p class="text-sm text-muted-foreground">
¿Estás seguro que deseas eliminar las <strong class="text-foreground"
>{selectedParts.length} partes seleccionadas</strong
>?
</p>
{/if}
<p class="mt-4 text-sm text-destructive">Esta acción no se puede deshacer.</p>
</div>
<Dialog.Footer>
<Button variant="outline" onclick={() => (showDeleteDialog = false)}>Cancelar</Button>
<Button variant="destructive" disabled={isLoading} onclick={confirmDelete}>
{#if isLoading}
<RefreshCw class="mr-2 h-4 w-4 animate-spin" />
Eliminando...
{:else}
Eliminar
{/if}
</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>