Merge pull request 'fix/clases-selector' (#374) from fix/clases-selector into development
Reviewed-on: ADUANASOFT/anexo76#374
This commit is contained in:
@@ -19,42 +19,19 @@ function formatCurrency(amount: number | null, currency: string | null): string
|
||||
}
|
||||
|
||||
export type GoodsPartsColumnOptions = {
|
||||
canEdit?: boolean;
|
||||
canDelete?: boolean;
|
||||
canEdit?: boolean;
|
||||
canDelete?: boolean;
|
||||
};
|
||||
|
||||
export function createColumns(
|
||||
onSuccess?: () => void,
|
||||
options: GoodsPartsColumnOptions = {}
|
||||
onSuccess?: () => void,
|
||||
options: GoodsPartsColumnOptions = {}
|
||||
): ColumnDef<Part>[] {
|
||||
const { canEdit = false, canDelete = false } = options;
|
||||
const { canEdit = false, canDelete = false } = options;
|
||||
return [
|
||||
{
|
||||
id: "select",
|
||||
header: ({ table }) => {
|
||||
const headerCheckbox = createRawSnippet<[{ table: import("@tanstack/table-core").Table<Part> }]>((getTable) => {
|
||||
const { table: t } = getTable();
|
||||
return {
|
||||
render: () => {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'checkbox';
|
||||
input.checked = t.getIsAllPageRowsSelected();
|
||||
input.indeterminate = t.getIsSomePageRowsSelected();
|
||||
input.onchange = (e) => t.toggleAllPageRowsSelected(!!(e.target as HTMLInputElement).checked);
|
||||
// Usamos el componente Checkbox si es posible, pero para snippets crudos en TanStack 5
|
||||
// a veces es más directo un input o un Snippet de Svelte.
|
||||
// Aquí usaremos renderComponent para el Checkbox real.
|
||||
return "<span></span>";
|
||||
}
|
||||
};
|
||||
});
|
||||
return renderComponent(Checkbox, {
|
||||
checked: table.getIsAllPageRowsSelected(),
|
||||
indeterminate: table.getIsSomePageRowsSelected(),
|
||||
onCheckedChange: (value) => table.toggleAllPageRowsSelected(!!value),
|
||||
"aria-label": "Select all"
|
||||
});
|
||||
},
|
||||
header: "", // Cabecera vacía para igualar a Clases
|
||||
cell: ({ row }) => {
|
||||
return renderComponent(Checkbox, {
|
||||
checked: row.getIsSelected(),
|
||||
@@ -62,6 +39,7 @@ export function createColumns(
|
||||
"aria-label": "Select row"
|
||||
});
|
||||
},
|
||||
size: 40,
|
||||
enableSorting: false,
|
||||
enableHiding: false
|
||||
},
|
||||
@@ -74,16 +52,15 @@ export function createColumns(
|
||||
const statusSnippet = createRawSnippet<[{ active: boolean }]>((getStatus) => {
|
||||
const { active } = getStatus();
|
||||
return {
|
||||
render: () => active
|
||||
? `<span class="inline-flex items-center rounded-full bg-green-50 px-2 py-1 text-xs font-medium text-green-700 ring-1 ring-inset ring-green-600/20">Activo</span>`
|
||||
: `<span class="inline-flex items-center rounded-full bg-red-50 px-2 py-1 text-xs font-medium text-red-700 ring-1 ring-inset ring-red-600/10">Inactivo</span>`
|
||||
render: () => active
|
||||
? `<span class="inline-flex items-center rounded-full bg-green-100 px-2 py-0.5 text-[10px] font-bold uppercase text-green-700 dark:bg-green-900/30 dark:text-green-400">Activo</span>`
|
||||
: `<span class="inline-flex items-center rounded-full bg-red-100 px-2 py-0.5 text-[10px] font-bold uppercase text-red-700 dark:bg-red-900/30 dark:text-red-400">Inactivo</span>`
|
||||
};
|
||||
});
|
||||
return renderSnippet(statusSnippet, { active: row.original.is_active });
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
{
|
||||
accessorKey: "part_number",
|
||||
header: "No. Parte",
|
||||
@@ -100,7 +77,6 @@ export function createColumns(
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
{
|
||||
accessorKey: "description_spanish",
|
||||
header: "Descripción",
|
||||
@@ -110,7 +86,7 @@ export function createColumns(
|
||||
const { desc } = getDesc();
|
||||
return {
|
||||
render: () =>
|
||||
`<div class="max-w-[250px] truncate text-xs font-medium uppercase text-muted-foreground" title="${desc}">${desc || '-'}</div>`
|
||||
`<div class="max-w-[250px] truncate text-sm font-medium text-foreground" title="${desc}">${desc || '-'}</div>`
|
||||
};
|
||||
});
|
||||
return renderSnippet(descSnippet, { desc: row.original.description_spanish || '' });
|
||||
@@ -125,14 +101,14 @@ export function createColumns(
|
||||
const { desc } = getDesc();
|
||||
return {
|
||||
render: () =>
|
||||
`<div class="max-w-[200px] truncate text-xs text-muted-foreground/70" title="${desc}">${desc || '-'}</div>`
|
||||
`<div class="max-w-[200px] truncate text-sm text-muted-foreground" title="${desc}">${desc || '-'}</div>`
|
||||
};
|
||||
});
|
||||
return renderSnippet(descEnSnippet, { desc: row.original.description_english || '' });
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
// CLASE - Estilo de Clases (borde azul)
|
||||
{
|
||||
accessorKey: "part_class",
|
||||
header: "Clase",
|
||||
@@ -141,29 +117,33 @@ export function createColumns(
|
||||
const classSnippet = createRawSnippet<[{ cls: string }]>((getCls) => {
|
||||
const { cls } = getCls();
|
||||
return {
|
||||
render: () => `<div class="text-xs font-mono">${cls || '-'}</div>`
|
||||
render: () => cls
|
||||
? `<span class="inline-flex items-center rounded-md border border-blue-200 bg-blue-50 px-2 py-1 font-mono text-xs font-bold text-blue-700 dark:border-blue-800 dark:bg-blue-900/30 dark:text-blue-400">${cls}</span>`
|
||||
: `<span class="text-xs text-muted-foreground">-</span>`
|
||||
};
|
||||
});
|
||||
return renderSnippet(classSnippet, { cls: row.original.part_class || '' });
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
// TIPO - Estilo de Clases (píldora oscura)
|
||||
{
|
||||
accessorKey: "commercial_part_number",
|
||||
accessorKey: "commercial_part_number",
|
||||
header: "Tipo",
|
||||
cell: ({ row }) => {
|
||||
const typeSnippet = createRawSnippet<[{ val: string }]>((getType) => {
|
||||
const { val } = getType();
|
||||
return {
|
||||
render: () => `<div class="text-xs text-muted-foreground">${val || '-'}</div>`
|
||||
render: () => val
|
||||
? `<span class="rounded-full bg-slate-100 px-2 py-0.5 text-[10px] font-bold tracking-wider uppercase text-slate-700 dark:bg-slate-800 dark:text-slate-400">${val}</span>`
|
||||
: `<span class="text-xs text-muted-foreground">-</span>`
|
||||
};
|
||||
});
|
||||
return renderSnippet(typeSnippet, { val: row.original.commercial_part_number || '' });
|
||||
}
|
||||
},
|
||||
|
||||
// 7. U.M. (Unidad de Medida)
|
||||
// U.M. - Estilo de Clases (texto plano claro)
|
||||
{
|
||||
accessorKey: "unit_of_measure",
|
||||
header: "U.M.",
|
||||
@@ -171,15 +151,14 @@ export function createColumns(
|
||||
const umSnippet = createRawSnippet<[{ um: string | null }]>((getUm) => {
|
||||
const { um } = getUm();
|
||||
return {
|
||||
render: () =>
|
||||
`<span class="inline-flex items-center rounded-sm bg-blue-50 dark:bg-blue-900 text-blue-700 dark:text-blue-300 px-1.5 py-0.5 text-[10px] font-bold ring-1 ring-inset ring-blue-700/10">${um || '-'}</span>`
|
||||
render: () => `<div class="text-sm text-muted-foreground">${um || '-'}</div>`
|
||||
};
|
||||
});
|
||||
return renderSnippet(umSnippet, { um: row.original.unit_of_measure });
|
||||
}
|
||||
},
|
||||
|
||||
// 8. FRACCION
|
||||
// FRACCION - Estilo de Clases (texto naranja)
|
||||
{
|
||||
accessorKey: "fraction",
|
||||
header: "Fracción",
|
||||
@@ -188,7 +167,7 @@ export function createColumns(
|
||||
const fracSnippet = createRawSnippet<[{ fr: string }]>((getFrac) => {
|
||||
const { fr } = getFrac();
|
||||
return {
|
||||
render: () => `<span class="font-mono text-xs bg-slate-100 dark:bg-slate-800 px-1.5 py-0.5 rounded">${fr || '-'}</span>`
|
||||
render: () => `<span class="font-mono text-xs text-orange-600 dark:text-orange-400">${fr || '-'}</span>`
|
||||
};
|
||||
});
|
||||
return renderSnippet(fracSnippet, {
|
||||
@@ -197,7 +176,7 @@ export function createColumns(
|
||||
}
|
||||
},
|
||||
|
||||
// 9. U.M.T. (del inv_data)
|
||||
// U.M.T.
|
||||
{
|
||||
id: "stock_uom",
|
||||
header: "U.M.T.",
|
||||
@@ -205,14 +184,14 @@ export function createColumns(
|
||||
const umtSnippet = createRawSnippet<[{ umt: string }]>((getUmt) => {
|
||||
const { umt } = getUmt();
|
||||
return {
|
||||
render: () => `<div class="text-xs font-mono">${umt || '-'}</div>`
|
||||
render: () => `<div class="text-xs font-mono text-muted-foreground">${umt || '-'}</div>`
|
||||
};
|
||||
});
|
||||
return renderSnippet(umtSnippet, { umt: row.original.inv_data?.stock_uom || '' });
|
||||
}
|
||||
},
|
||||
|
||||
// 10. COSTO
|
||||
// COSTO - Texto plano
|
||||
{
|
||||
accessorKey: "unit_cost",
|
||||
header: "Costo",
|
||||
@@ -221,17 +200,17 @@ export function createColumns(
|
||||
const costSnippet = createRawSnippet<[{ amount: number | null, curr: string | null }]>((getCost) => {
|
||||
const { amount, curr } = getCost();
|
||||
return {
|
||||
render: () => `<div class="font-mono text-xs font-medium">${formatCurrency(amount, curr)}</div>`
|
||||
render: () => `<div class="font-mono text-xs font-medium text-foreground">${formatCurrency(amount, curr)}</div>`
|
||||
};
|
||||
});
|
||||
return renderSnippet(costSnippet, {
|
||||
amount: row.original.unit_cost,
|
||||
curr: row.original.currency_key
|
||||
return renderSnippet(costSnippet, {
|
||||
amount: row.original.unit_cost,
|
||||
curr: row.original.currency_key
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
// 11. MONEDA
|
||||
// MONEDA - Texto plano para igualar el estilo minimalista
|
||||
{
|
||||
accessorKey: "currency_key",
|
||||
header: "Moneda",
|
||||
@@ -239,14 +218,14 @@ export function createColumns(
|
||||
const currSnippet = createRawSnippet<[{ curr: string }]>((getCurr) => {
|
||||
const { curr } = getCurr();
|
||||
return {
|
||||
render: () => `<span class="inline-flex items-center rounded-sm bg-emerald-50 dark:bg-emerald-900 text-emerald-700 dark:text-emerald-300 px-1.5 py-0.5 text-[10px] font-bold">${curr || '-'}</span>`
|
||||
render: () => `<span class="text-[10px] font-bold text-emerald-600 dark:text-emerald-400">${curr || '-'}</span>`
|
||||
};
|
||||
});
|
||||
return renderSnippet(currSnippet, { curr: row.original.currency_key || '' });
|
||||
}
|
||||
},
|
||||
|
||||
// 12. PESO UNITARIO
|
||||
// PESO UNITARIO
|
||||
{
|
||||
accessorKey: "unit_weight",
|
||||
header: "Peso Unitario",
|
||||
@@ -256,19 +235,19 @@ export function createColumns(
|
||||
const { weight, type } = getWeight();
|
||||
return {
|
||||
render: () => {
|
||||
if (weight === null || weight === undefined) return '<span>-</span>';
|
||||
return `<div class="text-xs font-mono">${Number(weight).toFixed(4)} ${type || ''}</div>`;
|
||||
if (weight === null || weight === undefined) return '<span class="text-muted-foreground">-</span>';
|
||||
return `<div class="text-xs font-mono text-muted-foreground">${Number(weight).toFixed(4)} ${type || ''}</div>`;
|
||||
}
|
||||
};
|
||||
});
|
||||
return renderSnippet(weightSnippet, {
|
||||
return renderSnippet(weightSnippet, {
|
||||
weight: row.original.unit_weight,
|
||||
type: row.original.weight_type
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
// 13. U.M. EXIST (Stock UOM del inv_data)
|
||||
// U.M. EXIST
|
||||
{
|
||||
id: "equivalent_uom",
|
||||
header: "U.M. Exist",
|
||||
@@ -276,14 +255,14 @@ export function createColumns(
|
||||
const eqUomSnippet = createRawSnippet<[{ uom: string }]>((getUom) => {
|
||||
const { uom } = getUom();
|
||||
return {
|
||||
render: () => `<div class="text-xs">${uom || '-'}</div>`
|
||||
render: () => `<div class="text-xs text-muted-foreground">${uom || '-'}</div>`
|
||||
};
|
||||
});
|
||||
return renderSnippet(eqUomSnippet, { uom: row.original.inv_data?.equivalent_uom || '' });
|
||||
}
|
||||
},
|
||||
|
||||
// 14. FECHA MODIFICACIÓN
|
||||
// FECHA MODIFICACIÓN
|
||||
{
|
||||
accessorKey: "updated_at",
|
||||
header: "Fecha Modificación",
|
||||
@@ -291,7 +270,7 @@ export function createColumns(
|
||||
cell: ({ row }) => {
|
||||
const dateSnippet = createRawSnippet<[{ date: string }]>((getDate) => {
|
||||
const { date } = getDate();
|
||||
if (!date) return { render: () => '<span>-</span>' };
|
||||
if (!date) return { render: () => '<span class="text-muted-foreground">-</span>' };
|
||||
const formatted = new Date(date).toLocaleDateString('es-MX', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
@@ -311,11 +290,11 @@ export function createColumns(
|
||||
header: "",
|
||||
cell: ({ row }) => {
|
||||
return renderComponent(DataTableActions, {
|
||||
item: row.original,
|
||||
onSuccess,
|
||||
canEdit,
|
||||
canDelete
|
||||
});
|
||||
item: row.original,
|
||||
onSuccess,
|
||||
canEdit,
|
||||
canDelete
|
||||
});
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
@@ -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">
|
||||
{#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-2 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-primary/10'
|
||||
: '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-2 py-1">
|
||||
<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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user