Merge branch 'development' into current branch and resolve modify/delete conflict in items-tab-form.svelte

This commit is contained in:
2026-03-31 10:14:59 -05:00
43 changed files with 2782 additions and 530 deletions

View File

@@ -59,6 +59,8 @@ export interface A76ClassListParams {
class_code?: string;
description?: string;
q?: string; // Agregado por si usas búsqueda general
sort_by?: string;
sort_order?: 'asc' | 'desc';
}
// --- API OBJECT ---
@@ -112,12 +114,17 @@ export const classesApi = {
company_id: number;
page?: number;
page_size?: number;
sort_by?: string;
sort_order?: 'asc' | 'desc';
}): Promise<ApiResponse<A76Class[]>> => {
const query = new URLSearchParams({
company_id: params.company_id.toString(),
page: (params.page || 1).toString(),
page_size: (params.page_size || 1000).toString()
});
if (params.sort_by) query.append('sort_by', params.sort_by);
if (params.sort_order) query.append('sort_order', params.sort_order);
return api.get(`/v1/a76/classes/with-fa-data?${query.toString()}`);
}
};

View File

@@ -154,7 +154,9 @@ export const partsApi = {
company_id: number;
page?: number;
page_size?: number;
q?: string
q?: string;
sort_by?: string;
sort_order?: 'asc' | 'desc';
}) => {
const { company_id, page = 1, page_size = 50, q = '' } = params;
@@ -168,6 +170,9 @@ export const partsApi = {
queryParams.description = q;
}
if (params.sort_by) queryParams.sort_by = params.sort_by;
if (params.sort_order) queryParams.sort_order = params.sort_order;
const query = new URLSearchParams(queryParams);
return api.get<PartListResponse>(`/v1/a76/parts/?${query.toString()}`);

View File

@@ -274,6 +274,8 @@ export interface PedimentoFilters {
status?: string;
client_id?: number;
year?: string;
sort_by?: string;
sort_order?: 'asc' | 'desc' | string;
}
/**
@@ -288,19 +290,21 @@ export const pedimentosApi = {
* @param companyId - ID de la compañía (por defecto 1)
*/
list: (page = 1, pageSize = 50, filters?: PedimentoFilters, companyId?: number) => {
let url = `/v1/a76/pedimentos/?company_id=${companyId}&page=${page}&page_size=${pageSize}`;
const params = new URLSearchParams({
company_id: companyId?.toString() || '',
page: page.toString(),
page_size: pageSize.toString()
});
if (filters?.status) {
url += `&status=${encodeURIComponent(filters.status)}`;
}
if (filters?.client_id) {
url += `&client_id=${filters.client_id}`;
}
if (filters?.year) {
url += `&year=${encodeURIComponent(filters.year)}`;
if (filters) {
Object.entries(filters).forEach(([key, value]) => {
if (value !== undefined && value !== null && value !== '') {
params.append(key, value.toString());
}
});
}
return api.get<PedimentoListResponse>(url);
return api.get<PedimentoListResponse>(`/v1/a76/pedimentos/?${params.toString()}`);
},
/**

View File

@@ -21,7 +21,7 @@ export function createColumns(onSuccess?: () => void): ColumnDef<ExchangeRate>[]
cell: ({ row }) => {
const value = row.original.value;
if (value === null || value === undefined) return 'N/A';
return value.toFixed(6);
return Number(value).toFixed(6);
}
},
{

View File

@@ -18,7 +18,7 @@ export function createColumns(onSuccess?: () => void): ColumnDef<CustomsBrokerCo
{
accessorKey: 'amount',
header: 'Importe',
cell: ({ row }) => row.original.amount ? `$${row.original.amount.toFixed(2)}` : '-'
cell: ({ row }) => row.original.amount ? `$${Number(row.original.amount).toFixed(2)}` : '-'
},
{
accessorKey: 'priority',

View File

@@ -0,0 +1,139 @@
import type { ColumnDef } from "@tanstack/table-core";
import { renderSnippet } from "$lib/components/ui/data-table/index.js";
import { createRawSnippet } from "svelte";
import type { A76Class } from "$lib/api/dashboard/a76/classes";
export function createColumns(): ColumnDef<A76Class>[] {
return [
{
id: "select",
header: ({ table }) => {
return renderSnippet(
createRawSnippet(() => ({
render: () => `<div class="w-4"></div>`
}))
);
},
cell: ({ row }) => {
const isSelected = row.getIsSelected();
const checkboxSnippet = createRawSnippet<[{ selected: boolean }]>((getProps) => {
const { selected } = getProps();
return {
render: () => `<div class="flex items-center justify-center">
<input type="checkbox" class="h-4 w-4 rounded border-gray-300 text-primary focus:ring-primary cursor-pointer" ${selected ? 'checked' : ''} />
</div>`
};
});
return renderSnippet(checkboxSnippet, { selected: isSelected });
},
size: 40,
enableSorting: false,
enableHiding: false
},
{
accessorKey: "class_code",
header: "Clase",
enableSorting: true,
cell: ({ row }) => {
const codeSnippet = createRawSnippet<[{ code: string }]>((getProps) => {
const { code } = getProps();
return {
render: () => `<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">${code}</span>`
};
});
return renderSnippet(codeSnippet, { code: row.original.class_code });
}
},
{
accessorKey: "description_es",
header: "Descripción Español",
enableSorting: true,
cell: ({ row }) => {
const descSnippet = createRawSnippet<[{ desc: string | null }]>((getProps) => {
const { desc } = getProps();
return {
render: () => `<div class="text-sm font-medium">${desc || ''}</div>`
};
});
return renderSnippet(descSnippet, { desc: row.original.description_es });
}
},
{
accessorKey: "description_en",
header: "Descripción Inglés",
enableSorting: true,
cell: ({ row }) => {
const descSnippet = createRawSnippet<[{ desc: string | null }]>((getProps) => {
const { desc } = getProps();
return {
render: () => `<div class="text-sm text-muted-foreground">${desc || ''}</div>`
};
});
return renderSnippet(descSnippet, { desc: row.original.description_en });
}
},
{
accessorKey: "material_key",
header: "Tipo",
enableSorting: true,
cell: ({ row }) => {
const typeSnippet = createRawSnippet<[{ key: string | null }]>((getProps) => {
const { key } = getProps();
let colorClass = 'bg-slate-100 text-slate-700 dark:bg-slate-800 dark:text-slate-400';
if (key === 'MP') colorClass = 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400';
else if (key === 'SC') colorClass = 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400';
else if (key === 'DESP') colorClass = 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400';
return {
render: () => `<span class="rounded-full px-2 py-0.5 text-[10px] font-bold tracking-wider uppercase ${colorClass}">${key || ''}</span>`
};
});
return renderSnippet(typeSnippet, { key: row.original.material_key });
}
},
{
accessorKey: "unit_of_measure",
header: "U.M.",
enableSorting: true,
cell: ({ row }) => {
const umSnippet = createRawSnippet<[{ um: string | null }]>((getProps) => {
const { um } = getProps();
return {
render: () => `<div class="text-muted-foreground">${um || ''}</div>`
};
});
return renderSnippet(umSnippet, { um: row.original.unit_of_measure });
}
},
{
accessorKey: "fraction",
header: "Fracción",
enableSorting: true,
cell: ({ row }) => {
const fracSnippet = createRawSnippet<[{ fr: string | null }]>((getProps) => {
const { fr } = getProps();
return {
render: () => `<span class="font-mono text-xs text-orange-600 dark:text-orange-400">${fr || ''}</span>`
};
});
return renderSnippet(fracSnippet, { fr: row.original.fraction });
}
},
{
accessorKey: "us_fraction",
header: "Fracción US",
enableSorting: true,
cell: ({ row }) => {
const fracSnippet = createRawSnippet<[{ fr: string | null }]>((getProps) => {
const { fr } = getProps();
return {
render: () => `<span>${fr || '-'}</span>`
};
});
return renderSnippet(fracSnippet, { fr: row.original.us_fraction });
}
}
];
}
export const columns = createColumns();

View File

@@ -0,0 +1,164 @@
<script lang="ts" generics="TData, TValue">
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>[];
data: TData[];
loading: boolean;
selectedId?: number | null;
onRowClick?: (row: TData) => void;
sorting?: import("@tanstack/table-core").SortingState;
onSortingChange?: (sorting: import("@tanstack/table-core").SortingState) => void;
};
let {
data,
columns,
loading,
selectedId = null,
onRowClick,
sorting = [],
onSortingChange
}: DataTableProps<TData, TValue> = $props();
const table = createSvelteTable({
get data() {
return data;
},
columns,
getCoreRowModel: getCoreRowModel(),
state: {
get rowSelection() {
return selectedId ? { [selectedId]: true } : {};
},
get sorting() {
return sorting;
}
},
onSortingChange: (updater) => {
if (onSortingChange) {
const nextSorting = typeof updater === 'function' ? updater(sorting) : updater;
onSortingChange(nextSorting);
}
},
manualSorting: true,
enableRowSelection: true,
enableMultiRowSelection: false
});
</script>
<div class="w-full h-full flex flex-col overflow-hidden">
<div class="rounded-md border flex-1 overflow-auto bg-white dark:bg-black">
<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="whitespace-nowrap p-0">
{#if !header.isPlaceholder}
<button
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()}
>
<FlexRender
content={header.column.columnDef.header}
context={header.getContext()}
/>
{#if header.column.getCanSort()}
<div class="flex h-4 w-4 shrink-0 items-center justify-center">
{#if header.column.getIsSorted() === 'asc'}
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="lucide lucide-chevron-up text-primary"
><path d="m18 15-6-6-6 6" /></svg
>
{:else if header.column.getIsSorted() === 'desc'}
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="lucide lucide-chevron-down text-primary"
><path d="m6 9 6 6 6-6" /></svg
>
{:else}
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="lucide lucide-chevrons-up-down opacity-30 group-hover:opacity-100"
><path d="m7 15 5 5 5-5" /><path d="m7 9 5-5 5 5" /></svg
>
{/if}
</div>
{/if}
</button>
{/if}
</Table.Head>
{/each}
</Table.Row>
{/each}
</Table.Header>
<Table.Body>
{#each table.getRowModel().rows as row (row.id)}
<Table.Row
data-state={row.getIsSelected() && "selected"}
onclick={() => {
if (onRowClick) {
onRowClick(row.original);
}
}}
class="cursor-pointer hover:bg-muted/50 transition-colors {row.getIsSelected() ? 'bg-primary/10' : ''}"
>
{#each row.getVisibleCells() as cell (cell.id)}
<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">
{#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}
</Table.Body>
</Table.Root>
</div>
</div>

View File

@@ -3,6 +3,7 @@ import { renderComponent, renderSnippet } from "$lib/components/ui/data-table/in
import { createRawSnippet } from "svelte";
import DataTableActions from "./data-table-actions.svelte";
import type { Part } from "$lib/api/dashboard/a76/parts";
import { Checkbox } from "$lib/components/ui/checkbox/index.js";
/**
* Formatea moneda (USD/MXN)
@@ -18,10 +19,47 @@ function formatCurrency(amount: number | null, currency: string | null): string
export function createColumns(onSuccess?: () => void): ColumnDef<Part>[] {
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"
});
},
cell: ({ row }) => {
return renderComponent(Checkbox, {
checked: row.getIsSelected(),
onCheckedChange: (value) => row.toggleSelected(!!value),
"aria-label": "Select row"
});
},
enableSorting: false,
enableHiding: false
},
{
accessorKey: "is_active",
header: "Status",
enableSorting: true,
cell: ({ row }) => {
const statusSnippet = createRawSnippet<[{ active: boolean }]>((getStatus) => {
const { active } = getStatus();
@@ -39,6 +77,7 @@ export function createColumns(onSuccess?: () => void): ColumnDef<Part>[] {
{
accessorKey: "part_number",
header: "No. Parte",
enableSorting: true,
cell: ({ row }) => {
const pnSnippet = createRawSnippet<[{ pn: string }]>((getPn) => {
const { pn } = getPn();
@@ -55,6 +94,7 @@ export function createColumns(onSuccess?: () => void): ColumnDef<Part>[] {
{
accessorKey: "description_spanish",
header: "Descripción",
enableSorting: true,
cell: ({ row }) => {
const descSnippet = createRawSnippet<[{ desc: string }]>((getDesc) => {
const { desc } = getDesc();
@@ -86,6 +126,7 @@ export function createColumns(onSuccess?: () => void): ColumnDef<Part>[] {
{
accessorKey: "part_class",
header: "Clase",
enableSorting: true,
cell: ({ row }) => {
const classSnippet = createRawSnippet<[{ cls: string }]>((getCls) => {
const { cls } = getCls();
@@ -117,13 +158,11 @@ export function createColumns(onSuccess?: () => void): ColumnDef<Part>[] {
accessorKey: "unit_of_measure",
header: "U.M.",
cell: ({ row }) => {
const umSnippet = createRawSnippet<[{ um: string }]>((getUm) => {
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>`
`<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>`
};
});
return renderSnippet(umSnippet, { um: row.original.unit_of_measure });
@@ -134,6 +173,7 @@ export function createColumns(onSuccess?: () => void): ColumnDef<Part>[] {
{
accessorKey: "fraction",
header: "Fracción",
enableSorting: true,
cell: ({ row }) => {
const fracSnippet = createRawSnippet<[{ fr: string }]>((getFrac) => {
const { fr } = getFrac();
@@ -164,6 +204,7 @@ export function createColumns(onSuccess?: () => void): ColumnDef<Part>[] {
{
accessorKey: "unit_cost",
header: "Costo",
enableSorting: true,
cell: ({ row }) => {
const costSnippet = createRawSnippet<[{ amount: number | null, curr: string | null }]>((getCost) => {
const { amount, curr } = getCost();
@@ -197,13 +238,14 @@ export function createColumns(onSuccess?: () => void): ColumnDef<Part>[] {
{
accessorKey: "unit_weight",
header: "Peso Unitario",
enableSorting: true,
cell: ({ row }) => {
const weightSnippet = createRawSnippet<[{ weight: number | null, type: string | null }]>((getWeight) => {
const { weight, type } = getWeight();
return {
render: () => {
if (weight === null || weight === undefined) return '-';
return `<div class="text-xs font-mono">${weight.toFixed(4)} ${type || ''}</div>`;
if (weight === null || weight === undefined) return '<span>-</span>';
return `<div class="text-xs font-mono">${Number(weight).toFixed(4)} ${type || ''}</div>`;
}
};
});
@@ -233,10 +275,11 @@ export function createColumns(onSuccess?: () => void): ColumnDef<Part>[] {
{
accessorKey: "updated_at",
header: "Fecha Modificación",
enableSorting: true,
cell: ({ row }) => {
const dateSnippet = createRawSnippet<[{ date: string }]>((getDate) => {
const { date } = getDate();
if (!date) return { render: () => '-' };
if (!date) return { render: () => '<span>-</span>' };
const formatted = new Date(date).toLocaleDateString('es-MX', {
year: 'numeric',
month: '2-digit',

View File

@@ -82,9 +82,9 @@
<DropdownMenu.Label>Acciones</DropdownMenu.Label>
<DropdownMenu.Separator />
<DropdownMenu.Item onclick={handleEdit} >
<DropdownMenu.Item onSelect={handleEdit}>
<Pencil size={16} class="mr-2" />
Editar
<span>Editar</span>
</DropdownMenu.Item>
<DropdownMenu.Separator />

View File

@@ -13,6 +13,11 @@
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;
onRowClick?: (row: TData) => void;
};
let {
@@ -20,7 +25,12 @@
columns,
loading,
hasMore,
loadMore
loadMore,
sorting = [],
onSortingChange,
rowSelection = {},
onRowSelectionChange,
onRowClick
}: DataTableProps<TData, TValue> = $props();
const table = createSvelteTable({
@@ -28,7 +38,28 @@
return data;
},
columns,
getCoreRowModel: getCoreRowModel()
getCoreRowModel: getCoreRowModel(),
state: {
get sorting() {
return sorting;
},
get rowSelection() {
return rowSelection;
}
},
onSortingChange: (updater) => {
if (onSortingChange) {
const nextSorting = typeof updater === 'function' ? updater(sorting) : updater;
onSortingChange(nextSorting);
}
},
onRowSelectionChange: (updater) => {
if (onRowSelectionChange) {
const nextRowSelection = typeof updater === 'function' ? updater(rowSelection) : updater;
onRowSelectionChange(nextRowSelection);
}
},
manualSorting: true
});
let scrollContainer = $state<HTMLDivElement>();
@@ -66,12 +97,65 @@
{#each table.getHeaderGroups() as headerGroup (headerGroup.id)}
<Table.Row>
{#each headerGroup.headers as header (header.id)}
<Table.Head>
<Table.Head class="p-0">
{#if !header.isPlaceholder}
<FlexRender
content={header.column.columnDef.header}
context={header.getContext()}
/>
<button
class="group flex h-full w-full items-center gap-2 px-4 py-2 text-left hover:bg-muted/50"
onclick={header.column.getToggleSortingHandler()}
disabled={!header.column.getCanSort()}
>
<FlexRender
content={header.column.columnDef.header}
context={header.getContext()}
/>
{#if header.column.getCanSort()}
<div class="flex h-4 w-4 shrink-0 items-center justify-center">
{#if header.column.getIsSorted() === 'asc'}
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="lucide lucide-chevron-up text-primary"
><path d="m18 15-6-6-6 6" /></svg
>
{:else if header.column.getIsSorted() === 'desc'}
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="lucide lucide-chevron-down text-primary"
><path d="m6 9 6 6 6-6" /></svg
>
{:else}
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="lucide lucide-chevrons-up-down opacity-30 group-hover:opacity-100"
><path d="m7 15 5 5 5-5" /><path d="m7 9 5-5 5 5" /></svg
>
{/if}
</div>
{/if}
</button>
{/if}
</Table.Head>
{/each}
@@ -80,7 +164,11 @@
</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"}
onclick={() => onRowClick?.(row.original)}
class={onRowClick ? "cursor-pointer" : ""}
>
{#each row.getVisibleCells() as cell (cell.id)}
<Table.Cell>
<FlexRender

View File

@@ -121,6 +121,7 @@ export function createColumns(
{
accessorKey: "operation_type",
header: "Operación",
enableSorting: true,
cell: ({ row }) => {
const operationType = row.original.operation_type;
@@ -142,6 +143,7 @@ export function createColumns(
{
accessorKey: "invoice_type",
header: "Tipo Factura",
enableSorting: true,
cell: ({ row }) => {
const invoiceType = row.original.invoice_type;
const colorClass = getInvoiceTypeColor(invoiceType);
@@ -150,9 +152,7 @@ export function createColumns(
const { type, colorClass } = getProps();
return {
render: () =>
`<span class="inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium ${colorClass}">
${type || '-'}
</span>`
`<span class="inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium ${colorClass}">${type || '-'}</span>`
};
});
return renderSnippet(typeSnippet, { type: invoiceType, colorClass });
@@ -161,6 +161,7 @@ export function createColumns(
{
accessorKey: "invoice_number",
header: "Núm. Factura",
enableSorting: true,
cell: ({ row }) => {
const numberSnippet = createRawSnippet<[{ number?: string | null }]>((getNumber) => {
const { number } = getNumber();
@@ -210,6 +211,7 @@ export function createColumns(
{
accessorKey: "invoice_date",
header: "Fecha Factura",
enableSorting: true,
cell: ({ row }) => {
const dateSnippet = createRawSnippet<[{ date: string }]>((getDate) => {
const { date } = getDate();
@@ -240,6 +242,7 @@ export function createColumns(
{
accessorKey: "document_type",
header: "Tipo Doc.",
enableSorting: true,
cell: ({ row }) => {
const typeSnippet = createRawSnippet<[{ type?: string | null }]>((getType) => {
const { type } = getType();

View File

@@ -14,6 +14,8 @@
selectedIds?: number[];
onRowClick?: (row: TData) => void;
compact?: boolean;
sorting?: import("@tanstack/table-core").SortingState;
onSortingChange?: (sorting: import("@tanstack/table-core").SortingState) => void;
};
let {
@@ -24,7 +26,9 @@
loadMore,
selectedIds = [],
onRowClick,
compact = false
compact = false,
sorting = [],
onSortingChange
}: DataTableProps<TData, TValue> = $props();
const table = createSvelteTable({
@@ -42,12 +46,28 @@
selection[id.toString()] = true;
});
return selection;
},
get sorting() {
return sorting;
}
},
onStateChange: (updater: any) => {
if (onSortingChange) {
const currentState = table.getState();
const nextState = typeof updater === 'function' ? updater(currentState) : updater;
// Identify if this was a sorting update or at least contains sorting
if (nextState && nextState.sorting !== undefined) {
onSortingChange(nextState.sorting);
} else if (Array.isArray(nextState)) {
// Fallback for when updater might return just the array slice
onSortingChange(nextState);
}
}
},
enableRowSelection: true,
enableMultiRowSelection: true
// No necesitamos onRowSelectionChange porque controlamos el estado desde fuera
enableMultiRowSelection: true,
manualSorting: true // Sorting is handled server-side for this component
});
let scrollContainer = $state<HTMLDivElement>();
@@ -99,10 +119,67 @@
.join(' ')}
>
{#if !header.isPlaceholder}
<FlexRender
content={header.column.columnDef.header}
context={header.getContext()}
/>
<button
class={[
'flex w-full items-center justify-between gap-2',
header.column.getCanSort() && 'cursor-pointer select-none'
]
.filter(Boolean)
.join(' ')}
onclick={header.column.getToggleSortingHandler()}
>
<FlexRender
content={header.column.columnDef.header}
context={header.getContext()}
/>
{#if header.column.getCanSort()}
<div class="flex h-4 w-4 shrink-0 items-center justify-center">
{#if header.column.getIsSorted() === 'asc'}
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="lucide lucide-chevron-up text-primary"
><path d="m18 15-6-6-6 6" /></svg
>
{:else if header.column.getIsSorted() === 'desc'}
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="lucide lucide-chevron-down text-primary"
><path d="m6 9 6 6 6-6" /></svg
>
{:else}
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="lucide lucide-chevrons-up-down opacity-30 group-hover:opacity-100"
><path d="m7 15 5 5 5-5" /><path d="m7 9 5-5 5 5" /></svg
>
{/if}
</div>
{/if}
</button>
{/if}
</Table.Head>
{/each}

View File

@@ -264,7 +264,7 @@
<div class="flex-1 overflow-y-auto px-2">
<div class="space-y-2">
{#if line}
{#if editingItem}
{#if showRepairBlock}
<!-- Importación de Reparación: Genera Descarga? + Factura de Expo + Línea de Expo -->
<div class="space-y-3 rounded-md border border-zinc-200 bg-white p-3 dark:border-zinc-800 dark:bg-zinc-900">
@@ -481,13 +481,15 @@
<h3 class="text-[10px] font-semibold text-zinc-500 uppercase tracking-wide">Datos Principales</h3>
</div>
<div class="p-2">
<MainData
bind:lineItem={editingItem}
bind:quantities={editingItem.quantity!}
bind:financials={editingItem.financial!}
bind:customs={editingItem.customs!}
{invoice}
/>
{#if editingItem.quantity && editingItem.financial && editingItem.customs}
<MainData
bind:lineItem={editingItem}
bind:quantities={editingItem.quantity}
bind:financials={editingItem.financial}
bind:customs={editingItem.customs}
{invoice}
/>
{/if}
</div>
</div>
</div>
@@ -498,10 +500,12 @@
<h3 class="text-[10px] font-semibold text-zinc-500 uppercase tracking-wide">Configuración</h3>
</div>
<div class="p-2">
<ItemConfiguration
bind:lineItem={editingItem}
bind:descriptions={editingItem.description!}
/>
{#if editingItem.description}
<ItemConfiguration
bind:lineItem={editingItem}
bind:descriptions={editingItem.description}
/>
{/if}
</div>
</div>
</div>
@@ -519,38 +523,46 @@
<div class="mt-1.5">
<Tabs.Content value="generales" class="m-0">
<div class="grid grid-cols-1 md:grid-cols-2 gap-2">
<PackagesSection
bind:item={editingItem}
bind:lineItem={editingItem}
bind:descriptions={editingItem.description!}
bind:customs={editingItem.customs!}
bind:quantities={editingItem.quantity!}
invoice={invoice}
/>
<SummarySection
bind:financials={editingItem.financial!}
bind:quantities={editingItem.quantity!}
lineItem={editingItem}
{invoice}
/>
{#if editingItem.description && editingItem.customs && editingItem.quantity}
<PackagesSection
bind:item={editingItem}
bind:lineItem={editingItem}
bind:descriptions={editingItem.description}
bind:customs={editingItem.customs}
bind:quantities={editingItem.quantity}
invoice={invoice}
/>
{/if}
{#if editingItem.financial && editingItem.quantity}
<SummarySection
bind:financials={editingItem.financial}
bind:quantities={editingItem.quantity}
lineItem={editingItem}
{invoice}
/>
{/if}
</div>
</Tabs.Content>
<Tabs.Content value="continuacion" class="m-0 focus-visible:outline-none">
<TabContinuation
bind:lineItem={editingItem}
bind:descriptions={editingItem.description!}
visibility={visibility}
/>
{#if editingItem.description}
<TabContinuation
bind:lineItem={editingItem}
bind:descriptions={editingItem.description}
visibility={visibility}
/>
{/if}
</Tabs.Content>
<Tabs.Content value="series" class="m-0 focus-visible:outline-none">
<TabSeries
bind:descriptions={editingItem.description!}
bind:series={editingItem.series!}
lineItem={editingItem}
{invoice}
/>
{#if editingItem.description && editingItem.series}
<TabSeries
bind:descriptions={editingItem.description}
bind:series={editingItem.series}
lineItem={editingItem}
{invoice}
/>
{/if}
</Tabs.Content>
{#if visibility.showLabelingTab}

View File

@@ -64,12 +64,14 @@
: null
);
// Ensure descriptions.has_serial has default
$effect(() => {
if (descriptions && descriptions.has_serial === undefined) {
descriptions.has_serial = false;
const internalHasSerial = $derived(descriptions?.has_serial === true);
function toggleHasSerial() {
if (descriptions) {
descriptions.has_serial = !descriptions.has_serial;
}
});
}
const hasSerial = $derived(internalHasSerial);
// Ensure current serie has defaults for form fields
$effect(() => {
@@ -83,7 +85,6 @@
}
});
const hasSerial = $derived(Boolean(descriptions?.has_serial));
const invoiceNumber = $derived(invoice?.invoice_number || '');
const invoiceLine = $derived(lineItem?.line_number != null ? String(lineItem.line_number) : '');
const partNumber = $derived((lineItem as any)?.part_number_display || lineItem?.part_number || '');
@@ -137,7 +138,11 @@
<div class="flex items-center justify-between gap-2 flex-wrap">
<div class="flex items-center space-x-1.5">
<Checkbox id="has_serial" bind:checked={descriptions.has_serial} />
<Checkbox
id="has_serial"
checked={descriptions?.has_serial === true}
onCheckedChange={(v: boolean) => { if (descriptions) descriptions.has_serial = v; }}
/>
<Label for="has_serial" class="text-xs font-normal">Lleva serie (LLEVASERIE)</Label>
</div>
<Button

View File

@@ -139,16 +139,13 @@
})
);
// Derived state for easier binding and safety
let line = $derived(editingItem);
// Initialize missing nested objects if they don't exist
// Use editingItem directly and ensure it's safe
$effect(() => {
if (open && editingItem) {
if (editingItem && !editingItem.quantity) editingItem.quantity = {} as any;
if (editingItem && !editingItem.financial) editingItem.financial = {} as any;
if (editingItem && !editingItem.customs) editingItem.customs = {} as any;
if (editingItem && !editingItem.description) editingItem.description = {} as any;
if (!editingItem.quantity) editingItem.quantity = {} as any;
if (!editingItem.financial) editingItem.financial = {} as any;
if (!editingItem.customs) editingItem.customs = {} as any;
if (!editingItem.description) editingItem.description = {} as any;
if (editingItem.has_fda_code === undefined) editingItem.has_fda_code = false;
}
});
@@ -193,7 +190,8 @@
</div>
</div>
<div class="max-h-[calc(90vh-96px)] overflow-auto bg-slate-50/60 p-6 dark:bg-black">
{#if editingItem}
<div class="max-h-[calc(90vh-96px)] overflow-auto bg-slate-50/60 p-6 dark:bg-black">
<Tabs.Root bind:value={activeTab} class="mt-0">
<Tabs.List class="grid w-full grid-cols-4">
<Tabs.Trigger value="general">General</Tabs.Trigger>
@@ -262,8 +260,8 @@
<div class="space-y-2">
<Label for="quantity_general">Cantidad</Label>
{#if line?.quantity}
<Input id="quantity_general" type="number" step="0.00000001" min="0" bind:value={line.quantity.quantity} />
{#if editingItem?.quantity}
<Input id="quantity_general" type="number" step="0.00000001" min="0" bind:value={editingItem.quantity.quantity} />
{/if}
</div>
<div class="space-y-2">
@@ -271,7 +269,7 @@
<div class="flex gap-1">
<Input
id="unit_general"
value={(editingItem as any).unit_code || line?.quantity?.unit_of_measure || ''}
value={(editingItem as any).unit_code || editingItem?.quantity?.unit_of_measure || ''}
readonly
class="cursor-pointer bg-muted"
placeholder="Selecciona U.M."
@@ -285,8 +283,8 @@
<div class="space-y-2">
<Label for="unit_cost_capture">Costo Unitario</Label>
{#if line?.financial}
<Input id="unit_cost_capture" type="number" step="0.00000001" min="0" bind:value={line.financial.unit_cost_capture} />
{#if editingItem?.financial}
<Input id="unit_cost_capture" type="number" step="0.00000001" min="0" bind:value={editingItem.financial.unit_cost_capture} />
{/if}
</div>
<div class="space-y-2">
@@ -294,7 +292,7 @@
<div class="flex gap-1">
<Input
id="origin_country_general"
value={line?.customs?.origin_country || ''}
value={editingItem?.customs?.origin_country || ''}
readonly
class="cursor-pointer bg-muted"
placeholder="Selecciona país"
@@ -324,13 +322,15 @@
</div>
<div class="space-y-2">
<Label for="fraction_type_general">Tipo de Tarifa</Label>
<select id="fraction_type_general" bind:value={line.customs.fraction_type} class="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2">
<option value=""></option>
<option value="GENERAL">GENERAL</option>
<option value="PROSEC">PROSEC</option>
<option value="ALADI">ALADI</option>
<option value="TLCS">TLCS</option>
</select>
{#if editingItem?.customs}
<select id="fraction_type_general" bind:value={editingItem.customs.fraction_type} class="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2">
<option value=""></option>
<option value="GENERAL">GENERAL</option>
<option value="PROSEC">PROSEC</option>
<option value="ALADI">ALADI</option>
<option value="TLCS">TLCS</option>
</select>
{/if}
</div>
</div>
@@ -363,11 +363,11 @@
<!-- Campos específicos de SCAII -->
<div class="space-y-2">
<Label for="product_description">Descripción del Producto</Label>
{#if line?.description}
{#if editingItem?.description}
<Input
id="product_description"
placeholder="Descripción detallada del producto"
bind:value={line.description.description_spanish}
bind:value={editingItem.description.description_spanish}
/>
{/if}
</div>
@@ -375,12 +375,12 @@
<div class="grid grid-cols-2 gap-4">
<div class="space-y-2">
<Label for="sku">SKU</Label>
{#if line}
{#if editingItem}
<div class="flex gap-1">
<Input
id="sku"
placeholder="Código SKU del producto"
value={(line as any).part_number_display || ''}
value={(editingItem as any).part_number_display || ''}
readonly
class="cursor-pointer bg-muted"
onclick={() => (showPartDialog = true)}
@@ -393,8 +393,8 @@
</div>
<div class="space-y-2">
<Label for="batch">Lote</Label>
{#if line?.description}
<Input id="batch" placeholder="Número de lote" bind:value={line.description.lot} />
{#if editingItem?.description}
<Input id="batch" placeholder="Número de lote" bind:value={editingItem.description.lot} />
{/if}
</div>
</div>
@@ -405,11 +405,11 @@
<div class="space-y-4">
<div class="space-y-2">
<Label for="tariff_fraction">Fracción Arancelaria</Label>
{#if line?.customs}
{#if editingItem?.customs}
<Input
id="tariff_fraction"
placeholder="8 dígitos"
bind:value={line.customs.fraction}
bind:value={editingItem.customs.fraction}
/>
{/if}
</div>
@@ -417,21 +417,21 @@
<div class="grid grid-cols-2 gap-4">
<div class="space-y-2">
<Label for="product_type">Tipo de Producto</Label>
{#if line?.description}
{#if editingItem?.description}
<Textarea
id="product_type"
placeholder="Materia prima, producto terminado, etc."
bind:value={(line.description as any).extra_description_2}
bind:value={(editingItem.description as any).extra_description_2}
/>
{/if}
</div>
<div class="space-y-2">
<Label for="material_type">Tipo de Material</Label>
{#if line?.description}
{#if editingItem?.description}
<Textarea
id="material_type"
placeholder="Metal, plástico, etc."
bind:value={(line.description as any).extra_description_3}
bind:value={(editingItem.description as any).extra_description_3}
/>
{/if}
</div>
@@ -445,8 +445,8 @@
<div class="grid grid-cols-2 gap-4">
<div class="space-y-2">
<Label for="country_origin">País de Origen</Label>
{#if line?.customs}
<Input id="country_origin" placeholder="Código del país" bind:value={line.customs.origin_country} />
{#if editingItem?.customs}
<Input id="country_origin" placeholder="Código del país" bind:value={editingItem.customs.origin_country} />
{/if}
</div>
<div class="space-y-2">
@@ -463,22 +463,22 @@
<div class="grid grid-cols-2 gap-4">
<div class="space-y-2">
<Label for="quantity">Cantidad</Label>
{#if line?.quantity}
{#if editingItem?.quantity}
<Input
id="quantity"
type="number"
placeholder="0"
bind:value={line.quantity.quantity}
bind:value={editingItem.quantity.quantity}
/>
{/if}
</div>
<div class="space-y-2">
<Label for="unit">Unidad de Medida</Label>
{#if line?.quantity}
{#if editingItem?.quantity}
<Input
id="unit"
placeholder="PZA, KG, M, etc."
bind:value={line.quantity.unit_of_measure}
bind:value={editingItem.quantity.unit_of_measure}
/>
{/if}
</div>
@@ -487,25 +487,25 @@
<div class="grid grid-cols-2 gap-4">
<div class="space-y-2">
<Label for="net_weight">Peso Neto (KG)</Label>
{#if line?.quantity}
{#if editingItem?.quantity}
<Input
id="net_weight"
type="number"
step="0.01"
placeholder="0.00"
bind:value={line.quantity.net_weight}
bind:value={editingItem.quantity.net_weight}
/>
{/if}
</div>
<div class="space-y-2">
<Label for="gross_weight">Peso Bruto (KG)</Label>
{#if line?.quantity}
{#if editingItem?.quantity}
<Input
id="gross_weight"
type="number"
step="0.01"
placeholder="0.00"
bind:value={line.quantity.gross_weight}
bind:value={editingItem.quantity.gross_weight}
/>
{/if}
</div>
@@ -514,13 +514,13 @@
<div class="grid grid-cols-2 gap-4">
<div class="space-y-2">
<Label for="unit_cost_usd">Costo Unitario (USD)</Label>
{#if line?.financial}
{#if editingItem?.financial}
<Input
id="unit_cost_usd"
type="number"
step="0.0001"
placeholder="0.00"
bind:value={line.financial.unit_cost_usd}
bind:value={editingItem.financial.unit_cost_usd}
/>
{/if}
</div>
@@ -531,7 +531,7 @@
type="number"
step="0.01"
placeholder="0.00"
value={(line?.quantity?.quantity || 0) * (line?.financial?.unit_cost_usd || 0)}
value={(editingItem?.quantity?.quantity || 0) * (editingItem?.financial?.unit_cost_usd || 0)}
disabled
/>
</div>
@@ -541,22 +541,22 @@
<div class="grid grid-cols-2 gap-4">
<div class="space-y-2">
<Label for="packages">Número de Bultos</Label>
{#if line?.quantity}
{#if editingItem?.quantity}
<Input
id="packages"
type="number"
placeholder="0"
bind:value={(line.quantity as any).packages}
bind:value={(editingItem.quantity as any).packages}
/>
{/if}
</div>
<div class="space-y-2">
<Label for="package_type">Tipo de Empaque</Label>
{#if line?.quantity}
{#if editingItem?.quantity}
<Input
id="package_type"
placeholder="Caja, pallet, etc."
bind:value={(line.quantity as any).package_type}
bind:value={(editingItem.quantity as any).package_type}
/>
{/if}
</div>
@@ -565,24 +565,24 @@
<div class="grid grid-cols-2 gap-4">
<div class="space-y-2">
<Label for="imported_quantity">Cantidad Importada</Label>
{#if line?.quantity}
{#if editingItem?.quantity}
<Input
id="imported_quantity"
type="number"
placeholder="0"
bind:value={(line.quantity as any).quantity_imported}
bind:value={(editingItem.quantity as any).quantity_imported}
/>
{/if}
</div>
<div class="space-y-2">
<Label for="remaining_quantity">Cantidad Remanente</Label>
{#if line?.quantity}
{#if editingItem?.quantity}
<Input
id="remaining_quantity"
type="number"
placeholder="0"
value={(line.quantity.quantity || 0) -
((line.quantity as any).quantity_imported || 0)}
value={(editingItem.quantity.quantity || 0) -
((editingItem.quantity as any).quantity_imported || 0)}
disabled
/>
{/if}
@@ -596,11 +596,11 @@
<div class="space-y-4">
<div class="space-y-2">
<Label for="brand">Marca</Label>
{#if line?.description}
{#if editingItem?.description}
<Input
id="brand"
placeholder="Marca del producto"
bind:value={line.description.brand}
bind:value={editingItem.description.brand}
/>
{/if}
</div>
@@ -629,12 +629,12 @@
<div class="space-y-2">
<Label for="observations">Observaciones</Label>
{#if line?.description}
{#if editingItem?.description}
<textarea
id="observations"
class="flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50"
placeholder="Notas adicionales sobre el inventario..."
bind:value={line.description.extra_description}
bind:value={editingItem.description.extra_description}
></textarea>
{/if}
</div>
@@ -642,5 +642,11 @@
</Tabs.Content>
</Tabs.Root>
</div>
{:else}
<div class="flex flex-col items-center justify-center py-20 text-muted-foreground">
<Loader2 class="w-8 h-8 animate-spin mb-4" />
<p class="text-sm">Cargando datos de la partida...</p>
</div>
{/if}
</Sheet.Content>
</Sheet.Root>

File diff suppressed because it is too large Load Diff

View File

@@ -122,6 +122,7 @@ export function createColumns(onSuccess?: () => void): ColumnDef<Pedimento>[] {
{
accessorKey: "id",
header: "ID",
enableSorting: true,
cell: ({ row }) => {
const idSnippet = createRawSnippet<[{ id: number }]>((getId) => {
const { id } = getId();
@@ -136,6 +137,7 @@ export function createColumns(onSuccess?: () => void): ColumnDef<Pedimento>[] {
{
accessorKey: "pedimento_number",
header: "Número de Pedimento",
enableSorting: true,
cell: ({ row }) => {
const pedimento = row.original;
const customs2 = (pedimento.customs_office ?? '').toString().slice(0, 2);
@@ -154,6 +156,7 @@ export function createColumns(onSuccess?: () => void): ColumnDef<Pedimento>[] {
{
accessorKey: "pedimento_type",
header: "Tipo",
enableSorting: true,
meta: { className: "hidden md:table-cell" },
cell: ({ row }) => {
const typeSnippet = createRawSnippet<[{ type?: string | null }]>((getType) => {
@@ -169,6 +172,7 @@ export function createColumns(onSuccess?: () => void): ColumnDef<Pedimento>[] {
{
accessorKey: "pedimento_code",
header: "Clave",
enableSorting: true,
meta: { className: "hidden lg:table-cell" },
cell: ({ row }) => {
const codeSnippet = createRawSnippet<[{ code?: string | null }]>((getCode) => {
@@ -295,6 +299,7 @@ export function createColumns(onSuccess?: () => void): ColumnDef<Pedimento>[] {
{
accessorKey: "status",
header: "Estado",
enableSorting: true,
cell: ({ row }) => {
const status = row.original.status;
const colorClass = getStatusColor(status);
@@ -324,6 +329,7 @@ export function createColumns(onSuccess?: () => void): ColumnDef<Pedimento>[] {
});
return renderSnippet(headerSnippet, {});
},
enableSorting: true,
meta: { className: "hidden lg:table-cell" },
cell: ({ row }) => {
const valueSnippet = createRawSnippet<[{ value: string }]>((getValue) => {
@@ -383,6 +389,7 @@ export function createColumns(onSuccess?: () => void): ColumnDef<Pedimento>[] {
{
accessorKey: "created_at",
header: "Fecha de Creación",
enableSorting: true,
meta: { className: "hidden 2xl:table-cell" },
cell: ({ row }) => {
const dateSnippet = createRawSnippet<[{ date: string }]>((getDate) => {

View File

@@ -18,6 +18,8 @@
loadMore: () => void;
selectedId?: number | null;
onRowClick?: (row: TData) => void;
sorting?: import("@tanstack/table-core").SortingState;
onSortingChange?: (sorting: import("@tanstack/table-core").SortingState) => void;
};
let {
@@ -27,7 +29,9 @@
hasMore,
loadMore,
selectedId = null,
onRowClick
onRowClick,
sorting = [],
onSortingChange
}: DataTableProps<TData, TValue> = $props();
const table = createSvelteTable({
@@ -40,8 +44,18 @@
state: {
get rowSelection() {
return selectedId ? { [selectedId]: true } : {};
},
get sorting() {
return sorting;
}
},
onSortingChange: (updater) => {
if (onSortingChange) {
const nextSorting = typeof updater === 'function' ? updater(sorting) : updater;
onSortingChange(nextSorting);
}
},
manualSorting: true,
enableRowSelection: true,
enableMultiRowSelection: false
});
@@ -89,12 +103,65 @@
{#each table.getHeaderGroups() as headerGroup (headerGroup.id)}
<Table.Row>
{#each headerGroup.headers as header (header.id)}
<Table.Head class="whitespace-nowrap {header.column.columnDef.meta?.className || ''}">
<Table.Head class="whitespace-nowrap p-0 {header.column.columnDef.meta?.className || ''}">
{#if !header.isPlaceholder}
<FlexRender
content={header.column.columnDef.header}
context={header.getContext()}
/>
<button
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()}
>
<FlexRender
content={header.column.columnDef.header}
context={header.getContext()}
/>
{#if header.column.getCanSort()}
<div class="flex h-4 w-4 shrink-0 items-center justify-center">
{#if header.column.getIsSorted() === 'asc'}
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="lucide lucide-chevron-up text-primary"
><path d="m18 15-6-6-6 6" /></svg
>
{:else if header.column.getIsSorted() === 'desc'}
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="lucide lucide-chevron-down text-primary"
><path d="m6 9 6 6 6-6" /></svg
>
{:else}
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="lucide lucide-chevrons-up-down opacity-30 group-hover:opacity-100"
><path d="m7 15 5 5 5-5" /><path d="m7 9 5-5 5 5" /></svg
>
{/if}
</div>
{/if}
</button>
{/if}
</Table.Head>
{/each}

View File

@@ -6,6 +6,7 @@
import { toast } from 'svelte-sonner';
import { helpApi, type HelpArticle } from '$lib/api/help';
import { helpStore } from '$lib/stores/help.svelte';
import { browser } from '$app/environment';
// Nota: Estas librerías deben ser instaladas: npm install marked dompurify @types/dompurify
// Si no están, fallará el import. El usuario debe instalarlas.
@@ -133,96 +134,100 @@
</Sheet.Trigger>
<Sheet.Content side="right" class="w-[400px] sm:w-[540px]">
<Sheet.Header>
<Sheet.Title class="flex items-center gap-2">
{#if selectedArticle}
<Button variant="ghost" size="icon" onclick={() => (selectedArticle = null)}>
<ChevronLeft size={20} />
</Button>
{/if}
Base de Conocimientos
</Sheet.Title>
</Sheet.Header>
<Sheet.Title class="flex items-center gap-2">
{#if selectedArticle}
<Button variant="ghost" size="icon" onclick={() => (selectedArticle = null)}>
<ChevronLeft size={20} />
</Button>
{/if}
<span>Base de Conocimientos</span>
</Sheet.Title>
</Sheet.Header>
<div class="mt-6 flex h-[calc(100vh-120px)] flex-col">
{#if !selectedArticle}
<div class="space-y-4">
<div class="flex items-center justify-between">
<h3 class="text-lg font-medium">Artículos Disponibles</h3>
{#if isAdmin}
<Button variant="outline" size="sm" onclick={startCreate}>
<Plus size={16} class="mr-2" /> Agregar Nuevo
</Button>
<div class="mt-6 flex h-[calc(100vh-120px)] flex-col">
{#if !selectedArticle}
<div class="space-y-4">
<div class="flex items-center justify-between">
<h3 class="text-lg font-medium">Artículos Disponibles</h3>
{#if isAdmin}
<Button variant="outline" size="sm" onclick={startCreate}>
<Plus size={16} class="mr-2" /> <span>Agregar Nuevo</span>
</Button>
{/if}
</div>
{#if isLoading}
<p class="py-10 text-center text-sm text-muted-foreground">Cargando...</p>
{:else if articles.length === 0}
<p class="py-10 text-center text-sm text-muted-foreground">
No hay artículos de ayuda disponibles.
</p>
{/if}
<div class="grid gap-2">
{#each articles as article (article.uuid)}
<button
onclick={() => selectArticle(article)}
class="flex flex-col items-start rounded-lg border p-4 text-left transition-colors hover:bg-accent"
>
<span class="font-semibold">{article.title}</span>
<span class="text-xs text-muted-foreground">
Última edición: {browser ? new Date(article.updated_at).toLocaleDateString() : article.updated_at.split('T')[0]}
</span>
</button>
{/each}
</div>
</div>
{:else}
<div class="flex flex-1 flex-col gap-4">
{#if isEditing}
<div class="space-y-4">
<input
bind:value={editTitle}
class="w-full rounded-md border bg-transparent p-2 text-xl font-bold"
placeholder="Título del artículo"
/>
<textarea
bind:value={editContent}
class="min-h-[400px] w-full flex-1 rounded-md border bg-transparent p-4 font-mono text-sm focus:ring-1 focus:ring-primary focus:outline-none"
placeholder="Escribe en Markdown..."
></textarea>
<div class="flex justify-end gap-2">
<Button
variant="outline"
onclick={() => {
isEditing = false;
if (isCreating) selectedArticle = null;
isCreating = false;
}}
>
<X size={16} class="mr-2" /> <span>Cancelar</span>
</Button>
<Button onclick={saveChanges}>
<Save size={16} class="mr-2" /> <span>Guardar Cambios</span>
</Button>
</div>
</div>
{:else}
<div class="flex flex-col gap-4">
<div class="flex items-center justify-between">
<h2 class="text-2xl font-bold">{selectedArticle.title}</h2>
{#if isAdmin}
<Button variant="outline" size="sm" onclick={startEdit}>
<Edit2 size={16} class="mr-2" /> <span>Editar</span>
</Button>
{/if}
</div>
<div class="prose prose-sm max-w-none border-t pt-4 dark:prose-invert">
{#if browser}
{@html renderMarkdown(selectedArticle.content)}
{:else}
<div class="whitespace-pre-wrap">{selectedArticle.content}</div>
{/if}
</div>
</div>
{/if}
</div>
{#if isLoading}
<p class="py-10 text-center text-sm text-muted-foreground">Cargando...</p>
{:else if articles.length === 0}
<p class="py-10 text-center text-sm text-muted-foreground">
No hay artículos de ayuda disponibles.
</p>
{/if}
<div class="grid gap-2">
{#each articles as article}
<button
onclick={() => selectArticle(article)}
class="flex flex-col items-start rounded-lg border p-4 text-left transition-colors hover:bg-accent"
>
<span class="font-semibold">{article.title}</span>
<span class="text-xs text-muted-foreground"
>Última edición: {new Date(article.updated_at).toLocaleDateString()}</span
>
</button>
{/each}
</div>
</div>
{:else}
<div class="flex flex-1 flex-col gap-4">
{#if isEditing}
<div class="space-y-4">
<input
bind:value={editTitle}
class="w-full rounded-md border bg-transparent p-2 text-xl font-bold"
placeholder="Título del artículo"
/>
<textarea
bind:value={editContent}
class="min-h-[400px] w-full flex-1 rounded-md border bg-transparent p-4 font-mono text-sm focus:ring-1 focus:ring-primary focus:outline-none"
placeholder="Escribe en Markdown..."
></textarea>
<div class="flex justify-end gap-2">
<Button
variant="outline"
onclick={() => {
isEditing = false;
if (isCreating) selectedArticle = null;
isCreating = false;
}}
>
<X size={16} class="mr-2" /> Cancelar
</Button>
<Button onclick={saveChanges}>
<Save size={16} class="mr-2" /> Guardar Cambios
</Button>
</div>
</div>
{:else}
<div class="flex flex-col gap-4">
<div class="flex items-center justify-between">
<h2 class="text-2xl font-bold">{selectedArticle.title}</h2>
{#if isAdmin}
<Button variant="outline" size="sm" onclick={startEdit}>
<Edit2 size={16} class="mr-2" /> Editar
</Button>
{/if}
</div>
<div class="prose prose-sm max-w-none border-t pt-4 dark:prose-invert">
{@html renderMarkdown(selectedArticle.content)}
</div>
</div>
{/if}
</div>
{/if}
</div>
{/if}
</div>
</Sheet.Content>
</Sheet.Root>

View File

@@ -49,7 +49,8 @@ export function createSvelteTable<TData extends RowData>(options: TableOptions<T
);
const table = createTable(resolvedOptions);
let state = $state<Partial<TableState>>(table.initialState);
// Use JSON parse/stringify to ensure we get a clean, non-proxy initial state object
let state = $state<Partial<TableState>>(JSON.parse(JSON.stringify(table.initialState)));
function updateOptions() {
table.setOptions((prev) => {
@@ -105,8 +106,7 @@ export function mergeObjects<Sources extends readonly MaybeThunk<any>[]>(
return new Proxy(Object.create(null), {
get(_, key) {
const src = findSourceWithKey(key);
return src?.[key as never];
return src ? src[key as never] : undefined;
},
has(_, key) {

View File

@@ -7,7 +7,13 @@ export interface Item {
// Helper function to check if an object has any meaningful values
export function hasValues(obj: any): boolean {
if (!obj || typeof obj !== 'object') return false;
return Object.values(obj).some(
// Si el objeto está intencionalmente vacío o tiene campos que serán usados,
// es mejor dejar que el backend valide si es requerido.
const values = Object.values(obj);
if (values.length === 0) return false;
return values.some(
(val) =>
val !== undefined &&
val !== null &&
@@ -18,7 +24,9 @@ export function hasValues(obj: any): boolean {
// Clean nested data before sending to API
export function cleanLineData(line: any) {
const cleaned: any = { ...line };
// 1. First, deeply copy and unwrap any Svelte Proxies to ensure a clean JS object
const rawLine = JSON.parse(JSON.stringify(line));
const cleaned: any = { ...rawLine };
// Helper function to convert to number or undefined
const toNumberOrUndefined = (value: any): number | undefined => {
@@ -29,47 +37,88 @@ export function cleanLineData(line: any) {
return !isNaN(numValue) && isFinite(numValue) ? numValue : undefined;
};
// Convert integer fields
cleaned.part_number = toNumberOrUndefined(cleaned.part_number);
// Explicitly keep mandatory fields for a76 schema
cleaned.invoice_id = toNumberOrUndefined(rawLine.invoice_id);
cleaned.line_number = toNumberOrUndefined(rawLine.line_number);
// Reconstruction approach for core objects to be 100% sure
// We ALWAYS want these objects to exist in the payload even if all fields are null
// so the backend validation layer doesn't crash (AttributeError on None)
cleaned.financial = {
unit_cost_usd: rawLine.financial ? toNumberOrUndefined(rawLine.financial.unit_cost_usd) : undefined,
unit_cost_mxn: rawLine.financial ? toNumberOrUndefined(rawLine.financial.unit_cost_mxn) : undefined,
unit_cost_capture: rawLine.financial ? toNumberOrUndefined(rawLine.financial.unit_cost_capture) : undefined,
value_mc: rawLine.financial ? toNumberOrUndefined(rawLine.financial.value_mc) : undefined,
value_usd: rawLine.financial ? toNumberOrUndefined(rawLine.financial.value_usd) : undefined,
value_mxn: rawLine.financial ? toNumberOrUndefined(rawLine.financial.value_mxn) : undefined
};
cleaned.quantity = {
quantity: rawLine.quantity ? (toNumberOrUndefined(rawLine.quantity.quantity) || 0) : 0,
net_weight: rawLine.quantity ? toNumberOrUndefined(rawLine.quantity.net_weight) : undefined,
gross_weight: rawLine.quantity ? toNumberOrUndefined(rawLine.quantity.gross_weight) : undefined,
package_id: rawLine.quantity ? toNumberOrUndefined(rawLine.quantity.package_id) : undefined,
package_quantity: rawLine.quantity ? toNumberOrUndefined(rawLine.quantity.package_quantity) : undefined
// NOTE: unit_of_measure lives at the top-level LineItem, NOT inside quantity
};
cleaned.description = {
description_spanish: rawLine.description ? (rawLine.description.description_spanish || '') : '',
description_english: rawLine.description ? (rawLine.description.description_english || '') : '',
brand: rawLine.description ? (rawLine.description.brand || '') : '',
model: rawLine.description ? (rawLine.description.model || '') : ''
};
cleaned.customs = {
fraction: rawLine.customs ? (rawLine.customs.fraction || undefined) : undefined,
american_fraction: rawLine.customs ? (rawLine.customs.american_fraction || undefined) : undefined,
origin_country: rawLine.customs ? (rawLine.customs.origin_country || undefined) : undefined,
fraction_type: rawLine.customs ? (rawLine.customs.fraction_type || undefined) : undefined
};
// Convert integer fields (only if they look like numbers/IDs)
if (cleaned.part_number && !isNaN(Number(cleaned.part_number))) {
cleaned.part_number = toNumberOrUndefined(cleaned.part_number);
}
cleaned.component_part_number = toNumberOrUndefined(cleaned.component_part_number);
cleaned.class_id = toNumberOrUndefined(cleaned.class_id);
// Ensure part_number is preserved if it's a string (common in some modules)
if (rawLine.part_number && !cleaned.part_number) {
cleaned.part_number = rawLine.part_number;
}
cleaned.unit_of_measure = toNumberOrUndefined(cleaned.unit_of_measure);
cleaned.alternate_unit = toNumberOrUndefined(cleaned.alternate_unit);
// Remove display-only fields
// Remove ALL display-only and UI-specific fields that the backend schema doesn't know about
delete cleaned.class_code;
delete cleaned.class_unit_of_measure;
delete cleaned.class_description;
// UI specific fields that shouldn't be in the payload
delete cleaned.part_description_es;
delete cleaned.part_description_en;
delete cleaned.part_number_display; // UI display field for part number string
delete cleaned.unit_code;
delete cleaned.unit_description;
delete cleaned.includes_subitems;
delete cleaned.payment_method_description;
// Only delete part_number if it's the string code from UI, but schema expects int ID.
// In this codebase, if part_number is populated from existing data, it's an ID.
// If it's a new item, it might be cleaned.
// Remove any other unknown top-level display fields
delete (cleaned as any).class_unit_of_measure_description;
// Remove display-only fields from nested objects
if (cleaned.customs) {
delete cleaned.customs.origin_country_name;
delete cleaned.customs.fraction_description;
if (!hasValues(cleaned.customs)) delete cleaned.customs;
// DO NOT delete if empty - backend needs the object structure
}
if (cleaned.fa_data) {
delete cleaned.fa_data.includes_subitems;
if (!hasValues(cleaned.fa_data)) delete cleaned.fa_data;
// Keep fa_data as is otherwise (unwrapped by stringify/parse above)
}
// Remove empty nested objects
if (cleaned.financial && !hasValues(cleaned.financial)) delete cleaned.financial;
if (cleaned.quantity && !hasValues(cleaned.quantity)) delete cleaned.quantity;
if (cleaned.description && !hasValues(cleaned.description)) delete cleaned.description;
if (cleaned.reference && !hasValues(cleaned.reference)) delete cleaned.reference;
if (cleaned.series != null) {
const arr = Array.isArray(cleaned.series) ? cleaned.series : [cleaned.series];
cleaned.series = arr

View File

@@ -10,6 +10,8 @@
import { faClassesApi, type FAClass } from '$lib/api/dashboard/a24/fa_classes';
import { companyStore } from '$lib/stores/company.svelte';
import { onMount } from 'svelte';
import DataTable from '$lib/components/dashboard/goods/classes/data-table.svelte';
import { columns } from '$lib/components/dashboard/goods/classes/columns';
// Tipo extendido que combina A76Class y FAClass
interface FixedAssetClassExtended extends A76Class {
@@ -39,6 +41,7 @@
let showDeleteDialog = $state(false);
let validationError = $state<string>('');
let isSaving = $state(false);
let sorting = $state<import("@tanstack/table-core").SortingState>([]);
// Estado del formulario
let formData = $state({
@@ -56,32 +59,26 @@
bom: ''
});
// Clases filtradas según búsqueda
// Clases filtradas según búsqueda (mantenemos filtrado local para compatibilidad inmediata)
const filteredClasses = $derived(
classes.filter((c) => {
// Filtro por código de clase
const matchesCode =
!searchTerm || c.class_code.toLowerCase().includes(searchTerm.toLowerCase());
// Filtro por descripción (español o inglés)
const matchesDescription =
!searchDescription ||
const matchesCode = !searchTerm || c.class_code.toLowerCase().includes(searchTerm.toLowerCase());
const matchesDescription = !searchDescription ||
(c.description_es?.toLowerCase().includes(searchDescription.toLowerCase()) ?? false) ||
(c.description_en?.toLowerCase().includes(searchDescription.toLowerCase()) ?? false);
// Filtro por tipo de material
const matchesType =
!searchType || (c.material_key?.toLowerCase().includes(searchType.toLowerCase()) ?? false);
// Filtro por fracción arancelaria
const matchesFraction =
!searchFraction ||
(c.fraction?.toLowerCase().includes(searchFraction.toLowerCase()) ?? false);
const matchesType = !searchType || (c.material_key?.toLowerCase().includes(searchType.toLowerCase()) ?? false);
const matchesFraction = !searchFraction || (c.fraction?.toLowerCase().includes(searchFraction.toLowerCase()) ?? false);
return matchesCode && matchesDescription && matchesType && matchesFraction;
})
);
// Efecto para reaccionar al cambio de ordenamiento
$effect(() => {
if (sorting.length >= 0) {
loadClasses();
}
});
// Reactively load classes when company changes
$effect(() => {
const companyId = companyStore.activeCompany?.id;
@@ -102,7 +99,9 @@
const response = await classesApi.getWithFAData({
company_id: companyId,
page: 1,
page_size: 1000
page_size: 1000,
sort_by: sorting.length > 0 ? sorting[0].id : undefined,
sort_order: sorting.length > 0 ? (sorting[0].desc ? 'desc' : 'asc') : undefined
});
if (response.data) {
@@ -295,82 +294,16 @@
</div>
<!-- Tabla de clases -->
<div class="flex-1 overflow-auto">
<table class="w-full text-sm">
<thead class="border-b bg-white text-gray-900 dark:bg-black dark:text-white">
<tr>
<th class="w-8 px-2 py-2 text-left">
<input type="checkbox" class="h-4 w-4" />
</th>
<th class="px-2 py-2 text-left">Clase</th>
<th class="px-2 py-2 text-left">Descripción Español</th>
<th class="px-2 py-2 text-left">Descripción Inglés</th>
<th class="px-2 py-2 text-left">Tipo</th>
<th class="px-2 py-2 text-left">U.M</th>
<th class="px-2 py-2 text-left">Fracción</th>
<th class="px-2 py-2 text-left">U.M.T.</th>
<th class="px-2 py-2 text-left">Fracción US</th>
</tr>
</thead>
<tbody>
{#if isLoading}
<tr>
<td colspan="10" class="py-8 text-center text-muted-foreground">Cargando...</td>
</tr>
{:else if filteredClasses.length === 0}
<tr>
<td colspan="10" class="py-8 text-center text-muted-foreground">
No hay clases de activo fijo registradas
</td>
</tr>
{:else}
{#each filteredClasses as cls (cls.id)}
<tr
class="cursor-pointer border-b transition-colors {selectedClass?.id === cls.id
? 'bg-gray-300 dark:bg-gray-600'
: 'hover:bg-gray-100 dark:hover:bg-gray-700'}"
onclick={() => selectClass(cls)}
>
<td class="px-2 py-1">
<input
type="checkbox"
checked={selectedClass?.id === cls.id}
class="h-4 w-4"
/>
</td>
<td class="px-2 py-1">
<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.class_code}
</span>
</td>
<td class="px-2 py-1 text-sm font-medium">{cls.description_es || ''}</td>
<td class="px-2 py-1">{cls.description_en || ''}</td>
<td class="px-2 py-1">
<span
class="rounded-full px-2 py-0.5 text-[10px] font-bold tracking-wider uppercase {cls.material_key ===
'MP'
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400'
: cls.material_key === 'SC'
? 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400'
: cls.material_key === 'DESP'
? 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400'
: 'bg-slate-100 text-slate-700 dark:bg-slate-800 dark:text-slate-400'}"
>
{cls.material_key || ''}
</span>
</td>
<td class="px-2 py-1 text-muted-foreground">{cls.unit_of_measure || ''}</td>
<td class="px-2 py-1 font-mono text-xs text-orange-600 dark:text-orange-400"
>{cls.fraction || ''}</td
> <td class="px-2 py-1 text-xs text-muted-foreground">-</td>
<td class="px-2 py-1">{cls.us_fraction || '-'}</td>
</tr>
{/each}
{/if}
</tbody>
</table>
<div class="flex-1 overflow-hidden p-0">
<DataTable
data={filteredClasses}
{columns}
loading={isLoading}
selectedId={selectedClass?.id}
onRowClick={selectClass}
{sorting}
onSortingChange={(newSorting) => (sorting = newSorting)}
/>
</div>
</div>
</div>

View File

@@ -8,8 +8,10 @@
import { clientsProvidersApi } from '$lib/api/dashboard/a76/clients-providers';
import { companyStore } from '$lib/stores/company.svelte';
import { useShortcuts } from '$lib/hooks/use-shortcuts';
import { obtenerAtajosListaMercancias } from '$lib/config/shortcuts/dashboard/goods/list';
import { goto } from '$app/navigation';
import DataTable from '$lib/components/dashboard/goods/parts/data-table.svelte';
import { createColumns } from '$lib/components/dashboard/goods/parts/columns';
import { obtenerAtajosListaMercancias } from '$lib/config/shortcuts/dashboard/goods/list';
// Estado de la lista de partes
let parts = $state<Part[]>([]);
@@ -20,45 +22,40 @@
let searchDescription = $state('');
let searchClient = $state('');
let searchClass = $state('');
let systemFilter = $state<'ALL' | 'SCAI' | 'SCAF'>('ALL'); // SCAI = inv_data, SCAF = fa_data
let systemFilter = $state<'ALL' | 'SCAI' | 'SCAF'>('ALL');
let sorting = $state<import("@tanstack/table-core").SortingState>([{ id: 'updated_at', desc: true }]);
const columns = createColumns(() => loadParts());
// Partes filtradas según búsqueda
// Para mantener compatibilidad con el diseño original que usa filtros locales,
// pero ahora con soporte para ordenamiento en el servidor.
const filteredParts = $derived(
parts.filter((p) => {
// Filtro por número de parte
const matchesPartNumber =
!searchPartNumber || p.part_number.toLowerCase().includes(searchPartNumber.toLowerCase());
// Filtro por descripción (español o inglés)
const matchesDescription =
!searchDescription ||
(p.description_spanish?.toLowerCase().includes(searchDescription.toLowerCase()) ?? false) ||
(p.description_english?.toLowerCase().includes(searchDescription.toLowerCase()) ?? false);
// Filtro por cliente (Busca en nombre o ID)
const clientName = clientsMap[p.client_id] || '';
const matchesClient =
!searchClient ||
(p.client_id?.toString().includes(searchClient) ?? false) ||
clientName.toLowerCase().includes(searchClient.toLowerCase());
// Filtro por clase
const matchesClass =
!searchClass || (p.part_class?.toLowerCase().includes(searchClass.toLowerCase()) ?? false);
// Filtro por sistema (SCAI/SCAF)
let matchesSystem = true;
if (systemFilter === 'SCAI') {
matchesSystem = !!p.inv_data;
} else if (systemFilter === 'SCAF') {
matchesSystem = !!p.fa_data;
}
return (
matchesPartNumber && matchesDescription && matchesClient && matchesClass && matchesSystem
);
if (systemFilter === 'SCAI') matchesSystem = !!p.inv_data;
else if (systemFilter === 'SCAF') matchesSystem = !!p.fa_data;
return matchesPartNumber && matchesDescription && matchesClient && matchesClass && matchesSystem;
})
);
// Reaccionar al cambio de ordenamiento
$effect(() => {
if (sorting.length >= 0) {
loadParts();
}
});
// Reactively load parts when company changes
$effect(() => {
const companyId = companyStore.activeCompany?.id;
@@ -120,7 +117,9 @@
const response = await partsApi.list({
company_id: companyId,
page: 1,
page_size: 1000
page_size: 1000,
sort_by: sorting.length > 0 ? sorting[0].id : undefined,
sort_order: sorting.length > 0 ? (sorting[0].desc ? 'desc' : 'asc') : undefined
});
if (response.data) {
@@ -283,106 +282,17 @@
</div>
<!-- Tabla de partes -->
<div class="flex-1 overflow-auto">
<table class="w-full text-sm">
<thead class="border-b bg-white text-gray-900 dark:bg-black dark:text-white">
<tr>
<th class="w-8 px-2 py-2 text-left">
<input type="checkbox" class="h-4 w-4" />
</th>
<th class="px-2 py-2 text-left">Número de Parte</th>
<th class="px-2 py-2 text-left">Descripción</th>
<th class="px-2 py-2 text-left">Cliente</th>
<th class="px-2 py-2 text-left">Clase</th>
<th class="px-2 py-2 text-left">U.M.</th>
<th class="px-2 py-2 text-left">Fracción</th>
<th class="px-2 py-2 text-left">Sistema</th>
</tr>
</thead>
<tbody>
{#if isLoading}
<tr>
<td colspan="7" class="py-8 text-center text-muted-foreground">Cargando...</td>
</tr>
{:else if filteredParts.length === 0}
<tr>
<td colspan="7" class="py-8 text-center text-muted-foreground">
No hay partes registradas
</td>
</tr>
{:else}
{#each filteredParts as part (part.id)}
<tr
class="cursor-pointer border-b transition-colors {selectedPart?.id === part.id
? 'bg-gray-300 dark:bg-gray-600'
: 'hover:bg-gray-100 dark:hover:bg-gray-700'}"
onclick={() => selectPart(part)}
>
<td class="px-2 py-1">
<input
type="checkbox"
checked={selectedPart?.id === part.id}
class="h-4 w-4"
/>
</td>
<td class="px-2 py-1">
<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"
>
{part.part_number}
</span>
</td>
<td class="px-2 py-1 text-sm font-medium">{part.description_spanish || ''}</td>
<td class="px-2 py-1 text-muted-foreground">
<div class="flex flex-col">
<span class="text-xs font-medium"
>{clientsMap[part.client_id] || 'Sin cliente'}</span
>
</div>
</td>
<td class="px-2 py-1">
{#if part.part_class}
<span
class="rounded-full bg-purple-100 px-2 py-0.5 text-[10px] font-bold tracking-wider text-purple-700 uppercase dark:bg-purple-900/30 dark:text-purple-400"
>
{part.part_class}
</span>
{:else}
<span class="text-muted-foreground">-</span>
{/if}
</td>
<td class="px-2 py-1 text-muted-foreground">{part.unit_of_measure || '-'}</td>
<td class="px-2 py-1 font-mono text-xs text-orange-600 dark:text-orange-400"
>{part.fraction || '-'}</td
>
<td class="px-2 py-1">
{#if part.inv_data && part.fa_data}
<span
class="inline-flex items-center rounded-full border border-purple-200 bg-purple-50 px-2 py-0.5 text-[10px] font-bold tracking-wider text-purple-700 uppercase dark:border-purple-800 dark:bg-purple-900/30 dark:text-purple-400"
>
AMBOS
</span>
{:else if part.inv_data}
<span
class="inline-flex items-center rounded-full border border-sky-200 bg-sky-50 px-2 py-0.5 text-[10px] font-bold tracking-wider text-sky-700 uppercase dark:border-sky-800 dark:bg-sky-900/30 dark:text-sky-400"
>
SCAI
</span>
{:else if part.fa_data}
<span
class="inline-flex items-center rounded-full border border-amber-200 bg-amber-50 px-2 py-0.5 text-[10px] font-bold tracking-wider text-amber-700 uppercase dark:border-amber-800 dark:bg-amber-900/30 dark:text-amber-400"
>
SCAF
</span>
{:else}
<span class="text-muted-foreground"></span>
{/if}
</td>
</tr>
{/each}
{/if}
</tbody>
</table>
<div class="flex-1 overflow-hidden p-0">
<DataTable
data={filteredParts}
{columns}
loading={isLoading}
hasMore={false}
loadMore={() => {}}
{sorting}
onSortingChange={(newSorting) => (sorting = newSorting)}
onRowClick={selectPart}
/>
</div>
</div>
</div>

View File

@@ -67,6 +67,8 @@
project_number: data.filters?.project_number || '',
year: data.filters?.year || ''
});
let sorting = $state<import("@tanstack/table-core").SortingState>([]);
let isDownloadModalOpen = $state(false);
let isTransferenciaModalOpen = $state(false);
@@ -143,7 +145,7 @@
};
const currentFiltersKey = JSON.stringify(currentFilters);
// Limpiar selección cada vez que se modifica algún filtro
// Limpiar selección cada vez que se modifica algún filtro o el orden
if (currentFiltersKey !== lastFiltersKey) {
selectedInvoiceIds = [];
lastFiltersKey = currentFiltersKey;
@@ -156,6 +158,14 @@
}, 300); // Esperar 300ms después del último cambio
});
// Efecto para reaccionar al cambio de ordenamiento
$effect(() => {
// Cuando cambia el sorting, aplicamos filtros (que reinicia a la página 1)
if (sorting.length >= 0) {
applyFilters();
}
});
// Sincronizar token de cookies a localStorage al montar el componente
onMount(() => {
if (browser) {
@@ -248,7 +258,9 @@
invoice_type: filters.invoice_type || undefined,
invoice_number: filters.invoice_number || undefined,
project_number: filters.project_number || undefined,
year: filters.year || undefined
year: filters.year || undefined,
sort_by: sorting.length > 0 ? sorting[0].id : undefined,
sort_order: sorting.length > 0 ? (sorting[0].desc ? 'desc' : 'asc') : undefined
};
const response = await invoicesApi.list(companyId, currentPage + 1, pageSize, filterParams);
@@ -305,7 +317,9 @@
invoice_type: filters.invoice_type || undefined,
invoice_number: filters.invoice_number || undefined,
project_number: filters.project_number || undefined,
year: filters.year || undefined
year: filters.year || undefined,
sort_by: sorting.length > 0 ? sorting[0].id : undefined,
sort_order: sorting.length > 0 ? (sorting[0].desc ? 'desc' : 'asc') : undefined
};
const response = await invoicesApi.list(companyId, 1, pageSize, filterParams);
@@ -363,7 +377,9 @@
invoice_type: filters.invoice_type || undefined,
invoice_number: filters.invoice_number || undefined,
project_number: filters.project_number || undefined,
year: filters.year || undefined
year: filters.year || undefined,
sort_by: sorting.length > 0 ? sorting[0].id : undefined,
sort_order: sorting.length > 0 ? (sorting[0].desc ? 'desc' : 'asc') : undefined
};
const response = await invoicesApi.list(companyId, 1, pageSize, filterParams);
@@ -955,12 +971,14 @@
<Card.Content>
<DataTable
data={allItems}
{columns}
columns={createColumns()}
{loading}
{hasMore}
{loadMore}
selectedIds={selectedInvoiceIds}
onRowClick={handleRowClick}
{sorting}
onSortingChange={(newSorting) => (sorting = newSorting)}
/>
</Card.Content>
</Card.Root>

View File

@@ -31,6 +31,8 @@
year: ''
});
let sorting = $state<import("@tanstack/table-core").SortingState>([{ id: 'id', desc: true }]);
// Sincronizar token de cookies a localStorage al montar el componente
onMount(() => {
if (browser) {
@@ -79,6 +81,14 @@
let pageSize = $state(50);
let totalItems = $state(data.total || 0);
let loading = $state(false);
let isSaving = $state(false);
// Efecto para reaccionar al cambio de ordenamiento
$effect(() => {
if (sorting.length >= 0) {
applyFilters();
}
});
let hasMore = $derived(allItems.length < totalItems);
let error = $state<string | null>(data.error || null);
@@ -247,7 +257,9 @@
const filterParams = {
status: filters.status || undefined,
client_id: filters.client_id ? parseInt(filters.client_id) : undefined,
year: filters.year || undefined
year: filters.year || undefined,
sort_by: sorting.length > 0 ? sorting[0].id : undefined,
sort_order: sorting.length > 0 ? (sorting[0].desc ? 'desc' : 'asc') : undefined
};
const response = await pedimentosApi.list(currentPage + 1, pageSize, filterParams, companyId);
@@ -298,7 +310,9 @@
const filterParams = {
status: filters.status || undefined,
client_id: filters.client_id ? parseInt(filters.client_id) : undefined,
year: filters.year || undefined
year: filters.year || undefined,
sort_by: sorting.length > 0 ? sorting[0].id : undefined,
sort_order: sorting.length > 0 ? (sorting[0].desc ? 'desc' : 'asc') : undefined
};
const response = await pedimentosApi.list(1, pageSize, filterParams, companyId);
@@ -357,7 +371,9 @@
const filterParams = {
status: filters.status || undefined,
client_id: filters.client_id ? parseInt(filters.client_id) : undefined,
year: filters.year || undefined
year: filters.year || undefined,
sort_by: sorting.length > 0 ? sorting[0].id : undefined,
sort_order: sorting.length > 0 ? (sorting[0].desc ? 'desc' : 'asc') : undefined
};
const response = await pedimentosApi.list(1, pageSize, filterParams, companyId);
@@ -521,6 +537,8 @@
{loadMore}
{selectedId}
onRowClick={handleRowClick}
{sorting}
onSortingChange={(newSorting) => (sorting = newSorting)}
/>
</Card.Content>
</Card.Root>