feat: Implement API clients and UI components for managing units of measure and locations

- Added API client for OMA units of measure with CRUD operations.
- Created API client for general unit measures with CRUD functionality.
- Developed UI components for displaying and managing locations in a data table format.
- Implemented data table actions for unit measures, including create, edit, and delete functionalities.
- Integrated pagination and refresh capabilities in the data tables for both customs and general unit measures.
- Enhanced user experience with dialogs for creating and editing units of measure.
- Added server-side loading logic for fetching locations and units of measure with error handling.
This commit is contained in:
2025-12-08 17:34:03 -06:00
parent 648197b171
commit e8b8599335
38 changed files with 2060 additions and 225 deletions

View File

@@ -0,0 +1,20 @@
import type { ColumnDef } from '@tanstack/table-core';
export interface Location {
location_code: string;
location_description: string | null;
}
export function createColumns(): ColumnDef<Location>[] {
return [
{
accessorKey: 'location_code',
header: 'Código',
},
{
accessorKey: 'location_description',
header: 'Descripción',
cell: ({ row }) => row.original.location_description || '-'
}
];
}

View File

@@ -0,0 +1,76 @@
<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[];
pageCount: number;
totalItems: number;
};
let {
data,
columns,
totalItems
}: DataTableProps<TData, TValue> = $props();
const table = createSvelteTable({
get data() {
return data;
},
columns,
getCoreRowModel: getCoreRowModel(),
});
</script>
<div class="rounded-md border">
<Table.Root>
<Table.Header>
{#each table.getHeaderGroups() as headerGroup (headerGroup.id)}
<Table.Row>
{#each headerGroup.headers as header (header.id)}
<Table.Head>
{#if !header.isPlaceholder}
<FlexRender
content={header.column.columnDef.header}
context={header.getContext()}
/>
{/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"}>
{#each row.getVisibleCells() as cell (cell.id)}
<Table.Cell>
<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.
</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
</div>
<div class="flex items-center justify-end space-x-2 py-4">
<div class="flex-1 text-sm text-muted-foreground">
Total: {totalItems} ubicaciones únicas
</div>
</div>

View File

@@ -0,0 +1,32 @@
import type { ColumnDef } from '@tanstack/table-core';
import { renderComponent } from '$lib/components/ui/data-table';
import DataTableActions from './data-table-actions.svelte';
export interface UMCustomsMex {
id: number;
code: string;
description: string | null;
}
export function createColumns(onSuccess?: () => void): ColumnDef<UMCustomsMex>[] {
return [
{
accessorKey: 'code',
header: 'Código',
},
{
accessorKey: 'description',
header: 'Descripción',
cell: ({ row }) => row.original.description || '-'
},
{
id: 'actions',
cell: ({ row }) => {
return renderComponent(DataTableActions, {
item: row.original,
onSuccess
});
}
}
];
}

View File

@@ -0,0 +1,113 @@
<script lang="ts">
import { Button } from "$lib/components/ui/button";
import * as Dialog from "$lib/components/ui/dialog";
import { Input } from "$lib/components/ui/input";
import { Label } from "$lib/components/ui/label";
import { createUMCustomsMex, updateUMCustomsMex, type UMCustomsMex } from "$lib/api/dashboard/a76/general_catalogs/um-customs-mex";
let {
open = $bindable(false),
mode = 'create',
item = null,
onSuccess
}: {
open: boolean;
mode?: 'create' | 'edit';
item?: UMCustomsMex | null;
onSuccess?: () => void;
} = $props();
const isEdit = $derived(mode === 'edit');
const title = $derived(isEdit ? "Editar Unidad Customs MEX" : "Nueva Unidad Customs MEX");
let formData = $state({
code: '',
description: ''
});
let loading = $state(false);
let error = $state<string | null>(null);
$effect(() => {
if (open) {
if (isEdit && item) {
formData = {
code: item.code,
description: item.description || ''
};
} else {
formData = {
code: '',
description: ''
};
}
error = null;
}
});
async function handleSubmit() {
loading = true;
error = null;
try {
let response;
if (isEdit && item) {
response = await updateUMCustomsMex(item.id, {
code: formData.code,
description: formData.description || null
});
} else {
response = await createUMCustomsMex({
code: formData.code,
description: formData.description || null
});
}
if (response.error) {
error = response.error;
return;
}
open = false;
if (onSuccess) onSuccess();
} catch (e) {
error = 'Error de conexión';
console.error(e);
} finally {
loading = false;
}
}
</script>
<Dialog.Root bind:open>
<Dialog.Content class="sm:max-w-[425px]">
<Dialog.Header>
<Dialog.Title>{title}</Dialog.Title>
</Dialog.Header>
<div class="grid gap-4 py-4">
{#if error}
<div class="text-red-500 text-sm mb-2">{error}</div>
{/if}
<div class="grid grid-cols-4 items-center gap-4">
<Label for="code" class="text-right">Código</Label>
<Input id="code" bind:value={formData.code} class="col-span-3" disabled={loading} />
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label for="description" class="text-right">Descripción</Label>
<Input id="description" bind:value={formData.description} class="col-span-3" disabled={loading} />
</div>
</div>
<div class="flex justify-end gap-2">
<Button variant="outline" onclick={() => open = false} disabled={loading}>
Cancelar
</Button>
<Button onclick={handleSubmit} disabled={loading}>
{loading ? 'Guardando...' : 'Guardar'}
</Button>
</div>
</Dialog.Content>
</Dialog.Root>

View File

@@ -0,0 +1,79 @@
<script lang="ts">
import { Button } from "$lib/components/ui/button";
import * as DropdownMenu from "$lib/components/ui/dropdown-menu";
import { deleteUMCustomsMex, type UMCustomsMex } from "$lib/api/dashboard/a76/general_catalogs/um-customs-mex";
import { EllipsisVertical, Pencil, LoaderCircle, Trash2 } from 'lucide-svelte';
import CreateEditDialog from "./create-edit-dialog.svelte";
let {
item,
onSuccess
}: {
item: UMCustomsMex;
onSuccess?: () => void;
} = $props();
let loading = $state(false);
let error = $state<string | null>(null);
let dialogOpen = $state(false);
async function handleDelete() {
if (!confirm(`¿Estás seguro de eliminar la unidad "${item.code}"?`)) {
return;
}
loading = true;
error = null;
try {
const response = await deleteUMCustomsMex(item.id);
if (response.error) {
error = response.error;
alert(`Error al eliminar: ${response.error}`);
return;
}
if (onSuccess) onSuccess();
} catch (e) {
error = 'Error de conexión';
console.error(e);
alert('Error de conexión al eliminar');
} finally {
loading = false;
}
}
</script>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
{#snippet child({ props })}
<Button {...props} variant="ghost" size="icon" class="h-8 w-8 p-0">
<span class="sr-only">Abrir menú</span>
<EllipsisVertical class="h-4 w-4" />
</Button>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end">
<DropdownMenu.Label>Acciones</DropdownMenu.Label>
<DropdownMenu.Item onclick={() => dialogOpen = true}>
<Pencil class="mr-2 h-4 w-4" />
Editar
</DropdownMenu.Item>
<DropdownMenu.Item onclick={handleDelete} class="text-red-600">
{#if loading}
<LoaderCircle class="mr-2 h-4 w-4 animate-spin" />
{:else}
<Trash2 class="mr-2 h-4 w-4" />
{/if}
Eliminar
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
<CreateEditDialog
bind:open={dialogOpen}
mode="edit"
{item}
{onSuccess}
/>

View File

@@ -0,0 +1,106 @@
<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";
import { Button } from "$lib/components/ui/button";
import { goto } from "$app/navigation";
import { page } from "$app/stores";
type DataTableProps<TData, TValue> = {
columns: ColumnDef<TData, TValue>[];
data: TData[];
pageCount: number;
totalItems: number;
};
let {
data,
columns,
pageCount,
totalItems
}: DataTableProps<TData, TValue> = $props();
const table = createSvelteTable({
get data() {
return data;
},
columns,
getCoreRowModel: getCoreRowModel(),
manualPagination: true,
pageCount: pageCount,
});
function handlePageChange(newPage: number) {
const url = new URL($page.url);
url.searchParams.set('page', newPage.toString());
goto(url);
}
</script>
<div class="rounded-md border">
<Table.Root>
<Table.Header>
{#each table.getHeaderGroups() as headerGroup (headerGroup.id)}
<Table.Row>
{#each headerGroup.headers as header (header.id)}
<Table.Head>
{#if !header.isPlaceholder}
<FlexRender
content={header.column.columnDef.header}
context={header.getContext()}
/>
{/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"}>
{#each row.getVisibleCells() as cell (cell.id)}
<Table.Cell>
<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.
</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
</div>
<div class="flex items-center justify-end space-x-2 py-4">
<div class="flex-1 text-sm text-muted-foreground">
Total: {totalItems}
</div>
<div class="space-x-2">
<Button
variant="outline"
size="sm"
onclick={() => handlePageChange(Number($page.url.searchParams.get('page') || 1) - 1)}
disabled={Number($page.url.searchParams.get('page') || 1) <= 1}
>
Anterior
</Button>
<Button
variant="outline"
size="sm"
onclick={() => handlePageChange(Number($page.url.searchParams.get('page') || 1) + 1)}
disabled={Number($page.url.searchParams.get('page') || 1) >= pageCount}
>
Siguiente
</Button>
</div>
</div>

View File

@@ -0,0 +1,32 @@
import type { ColumnDef } from '@tanstack/table-core';
import { renderComponent } from '$lib/components/ui/data-table';
import DataTableActions from './data-table-actions.svelte';
export interface UnitMeasure {
id: number;
code: string;
description: string | null;
}
export function createColumns(onSuccess?: () => void): ColumnDef<UnitMeasure>[] {
return [
{
accessorKey: 'code',
header: 'Código',
},
{
accessorKey: 'description',
header: 'Descripción',
cell: ({ row }) => row.original.description || '-'
},
{
id: 'actions',
cell: ({ row }) => {
return renderComponent(DataTableActions, {
item: row.original,
onSuccess
});
}
}
];
}

View File

@@ -0,0 +1,113 @@
<script lang="ts">
import { Button } from "$lib/components/ui/button";
import * as Dialog from "$lib/components/ui/dialog";
import { Input } from "$lib/components/ui/input";
import { Label } from "$lib/components/ui/label";
import { createUnitMeasure, updateUnitMeasure, type UnitMeasure } from "$lib/api/dashboard/a76/general_catalogs/unit-measures";
let {
open = $bindable(false),
mode = 'create',
item = null,
onSuccess
}: {
open: boolean;
mode?: 'create' | 'edit';
item?: UnitMeasure | null;
onSuccess?: () => void;
} = $props();
const isEdit = $derived(mode === 'edit');
const title = $derived(isEdit ? "Editar Unidad de Medida" : "Nueva Unidad de Medida");
let formData = $state({
code: '',
description: ''
});
let loading = $state(false);
let error = $state<string | null>(null);
$effect(() => {
if (open) {
if (isEdit && item) {
formData = {
code: item.code,
description: item.description || ''
};
} else {
formData = {
code: '',
description: ''
};
}
error = null;
}
});
async function handleSubmit() {
loading = true;
error = null;
try {
let response;
if (isEdit && item) {
response = await updateUnitMeasure(item.id, {
code: formData.code,
description: formData.description || null
});
} else {
response = await createUnitMeasure({
code: formData.code,
description: formData.description || null
});
}
if (response.error) {
error = response.error;
return;
}
open = false;
if (onSuccess) onSuccess();
} catch (e) {
error = 'Error de conexión';
console.error(e);
} finally {
loading = false;
}
}
</script>
<Dialog.Root bind:open>
<Dialog.Content class="sm:max-w-[425px]">
<Dialog.Header>
<Dialog.Title>{title}</Dialog.Title>
</Dialog.Header>
<div class="grid gap-4 py-4">
{#if error}
<div class="text-red-500 text-sm mb-2">{error}</div>
{/if}
<div class="grid grid-cols-4 items-center gap-4">
<Label for="code" class="text-right">Código</Label>
<Input id="code" bind:value={formData.code} class="col-span-3" disabled={loading} />
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label for="description" class="text-right">Descripción</Label>
<Input id="description" bind:value={formData.description} class="col-span-3" disabled={loading} />
</div>
</div>
<div class="flex justify-end gap-2">
<Button variant="outline" onclick={() => open = false} disabled={loading}>
Cancelar
</Button>
<Button onclick={handleSubmit} disabled={loading}>
{loading ? 'Guardando...' : 'Guardar'}
</Button>
</div>
</Dialog.Content>
</Dialog.Root>

View File

@@ -0,0 +1,79 @@
<script lang="ts">
import { Button } from "$lib/components/ui/button";
import * as DropdownMenu from "$lib/components/ui/dropdown-menu";
import { deleteUnitMeasure, type UnitMeasure } from "$lib/api/dashboard/a76/general_catalogs/unit-measures";
import { EllipsisVertical, Pencil, LoaderCircle, Trash2 } from 'lucide-svelte';
import CreateEditDialog from "./create-edit-dialog.svelte";
let {
item,
onSuccess
}: {
item: UnitMeasure;
onSuccess?: () => void;
} = $props();
let loading = $state(false);
let error = $state<string | null>(null);
let dialogOpen = $state(false);
async function handleDelete() {
if (!confirm(`¿Estás seguro de eliminar la unidad "${item.code}"?`)) {
return;
}
loading = true;
error = null;
try {
const response = await deleteUnitMeasure(item.id);
if (response.error) {
error = response.error;
alert(`Error al eliminar: ${response.error}`);
return;
}
if (onSuccess) onSuccess();
} catch (e) {
error = 'Error de conexión';
console.error(e);
alert('Error de conexión al eliminar');
} finally {
loading = false;
}
}
</script>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
{#snippet child({ props })}
<Button {...props} variant="ghost" size="icon" class="h-8 w-8 p-0">
<span class="sr-only">Abrir menú</span>
<EllipsisVertical class="h-4 w-4" />
</Button>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end">
<DropdownMenu.Label>Acciones</DropdownMenu.Label>
<DropdownMenu.Item onclick={() => dialogOpen = true}>
<Pencil class="mr-2 h-4 w-4" />
Editar
</DropdownMenu.Item>
<DropdownMenu.Item onclick={handleDelete} class="text-red-600">
{#if loading}
<LoaderCircle class="mr-2 h-4 w-4 animate-spin" />
{:else}
<Trash2 class="mr-2 h-4 w-4" />
{/if}
Eliminar
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
<CreateEditDialog
bind:open={dialogOpen}
mode="edit"
{item}
{onSuccess}
/>

View File

@@ -0,0 +1,106 @@
<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";
import { Button } from "$lib/components/ui/button";
import { goto } from "$app/navigation";
import { page } from "$app/stores";
type DataTableProps<TData, TValue> = {
columns: ColumnDef<TData, TValue>[];
data: TData[];
pageCount: number;
totalItems: number;
};
let {
data,
columns,
pageCount,
totalItems
}: DataTableProps<TData, TValue> = $props();
const table = createSvelteTable({
get data() {
return data;
},
columns,
getCoreRowModel: getCoreRowModel(),
manualPagination: true,
pageCount: pageCount,
});
function handlePageChange(newPage: number) {
const url = new URL($page.url);
url.searchParams.set('page', newPage.toString());
goto(url);
}
</script>
<div class="rounded-md border">
<Table.Root>
<Table.Header>
{#each table.getHeaderGroups() as headerGroup (headerGroup.id)}
<Table.Row>
{#each headerGroup.headers as header (header.id)}
<Table.Head>
{#if !header.isPlaceholder}
<FlexRender
content={header.column.columnDef.header}
context={header.getContext()}
/>
{/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"}>
{#each row.getVisibleCells() as cell (cell.id)}
<Table.Cell>
<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.
</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
</div>
<div class="flex items-center justify-end space-x-2 py-4">
<div class="flex-1 text-sm text-muted-foreground">
Total: {totalItems}
</div>
<div class="space-x-2">
<Button
variant="outline"
size="sm"
onclick={() => handlePageChange(Number($page.url.searchParams.get('page') || 1) - 1)}
disabled={Number($page.url.searchParams.get('page') || 1) <= 1}
>
Anterior
</Button>
<Button
variant="outline"
size="sm"
onclick={() => handlePageChange(Number($page.url.searchParams.get('page') || 1) + 1)}
disabled={Number($page.url.searchParams.get('page') || 1) >= pageCount}
>
Siguiente
</Button>
</div>
</div>

View File

@@ -179,7 +179,7 @@ export function getSidebarData(): SidebarData {
},
{
title: m["sidebar.general_catalogs.incoterms"](),
url: "#",
url: "/dashboard/reference_data/incoterms",
},
{
title: m["sidebar.general_catalogs.inpc"](),
@@ -195,7 +195,7 @@ export function getSidebarData(): SidebarData {
},
{
title: m["sidebar.general_catalogs.valuation_methods"](),
url: "#",
url: "/dashboard/reference_data/valuation_methods",
},
{
title: m["sidebar.general_catalogs.countries"](),
@@ -207,23 +207,23 @@ export function getSidebarData(): SidebarData {
},
{
title: m["sidebar.general_catalogs.unit_measures"](),
url: "/dashboard/general_catalogs/units_of_measures",
url: "/dashboard/general_catalogs/units_of_measure/general",
},
{
title: m["sidebar.general_catalogs.um_customs_mex"](),
url: "#",
url: "/dashboard/general_catalogs/units_of_measure/customs",
},
{
title: m["sidebar.general_catalogs.um_customs_ame"](),
url: "#",
url: "/dashboard/general_catalogs/units_of_measure/american",
},
{
title: m["sidebar.general_catalogs.um_ace"](),
url: "#",
url: "/dashboard/general_catalogs/units_of_measure/ace",
},
{
title: m["sidebar.general_catalogs.um_oma"](),
url: "#",
url: "/dashboard/general_catalogs/units_of_measure/oma",
},
{
title: m["sidebar.general_catalogs.conversions"](),
@@ -239,7 +239,7 @@ export function getSidebarData(): SidebarData {
},
{
title: m["sidebar.general_catalogs.currency_types"](),
url: "#",
url: "/dashboard/reference_data/currency_types",
},
{
title: m["sidebar.general_catalogs.multi_currency"](),
@@ -247,7 +247,7 @@ export function getSidebarData(): SidebarData {
},
{
title: m["sidebar.general_catalogs.invoice_types"](),
url: "#",
url: "/dashboard/reference_data/invoice_types",
},
{
title: m["sidebar.general_catalogs.electronic_signatures"](),
@@ -259,20 +259,16 @@ export function getSidebarData(): SidebarData {
},
{
title: m["sidebar.general_catalogs.customs_warehouses"](),
url: "#",
url: "/dashboard/reference_data/customs_warehouses",
},
{
title: m["sidebar.general_catalogs.locations"](),
url: "#",
url: "/dashboard/general_catalogs/locations",
},
{
title: m["sidebar.general_catalogs.doda"](),
url: "/dashboard/general_catalogs/doda",
},
{
title: m["sidebar.general_catalogs.packing_list"](),
url: "#",
},
{
title: m["sidebar.general_catalogs.prevalidators"](),
url: "/dashboard/general_catalogs/prevalidators",
@@ -281,10 +277,6 @@ export function getSidebarData(): SidebarData {
title: m["sidebar.general_catalogs.electronic_notices"](),
url: "/dashboard/general_catalogs/electronic_notices",
},
{
title: m["sidebar.general_catalogs.back_flush"](),
url: "#",
},
{
title: m["sidebar.general_catalogs.crossing_notice"](),
url: "#",