Merge origin/development into fix/bug-pedimento

This commit is contained in:
2026-02-26 12:27:02 -06:00
41 changed files with 3281 additions and 298 deletions

View File

@@ -1,4 +1,5 @@
import { api } from '$lib/api';
import { companyStore } from '$lib/stores/company.svelte'; // <--- NUEVO: Importamos el store para el fallback
import type { ApiResponse } from '$lib/api';
export interface CustomsBroker {
@@ -26,6 +27,8 @@ export interface CustomsBroker {
}
export interface CustomsBrokerVU {
tenant_id?: string | null;
company_id?: string | null;
certificate_path?: string | null;
key_path?: string | null;
access_key?: string | null;
@@ -95,7 +98,9 @@ export interface CustomsBrokerListResponse {
*/
export const customsBrokersApi = {
list: (companyId: string, page = 1, pageSize = 50) => {
return api.get<CustomsBrokerListResponse>(`/v1/a76/customs-brokers?company_id=${companyId}&page=${page}&page_size=${pageSize}`);
return api.get<CustomsBrokerListResponse>(
`/v1/a76/customs-brokers?company_id=${companyId}&page=${page}&page_size=${pageSize}`
);
},
get: (brokerKey: string, companyId: string) => {
@@ -111,20 +116,47 @@ export const customsBrokersApi = {
*/
update: (brokerKey: string, data: CreateCustomsBrokerData) => {
const companyId = data.company_id;
return api.put<CustomsBroker>(`/v1/a76/customs-brokers/${brokerKey}/?company_id=${companyId}`, data);
return api.put<CustomsBroker>(
`/v1/a76/customs-brokers/${brokerKey}/?company_id=${companyId}`,
data
);
},
/**
* Elimina un agente aduanal
*/
delete: (brokerKey: string, companyId: string) => {
return api.delete<CustomsBroker>(`/v1/a76/customs-brokers/${brokerKey}?company_id=${companyId}`);
return api.delete<CustomsBroker>(
`/v1/a76/customs-brokers/${brokerKey}?company_id=${companyId}`
);
},
/**
* Actualiza la información de Ventanilla Única (VU)
*/
updateVU: (brokerKey: string, data: CustomsBrokerVU, companyId: string) => {
return api.put<CustomsBrokerVU>(`/v1/a76/customs-broker-vu/${brokerKey}?company_id=${companyId}`, data);
// LOGICA DE RESCATE:
// Si companyId llega nulo/undefined, intentamos obtenerlo del store global
let finalCompanyId = companyId;
if (!finalCompanyId && companyStore.activeCompany?.id) {
finalCompanyId = companyStore.activeCompany.id.toString();
console.warn("WARN: companyId no fue provisto a updateVU, usando companyStore:", finalCompanyId);
}
// Aseguramos que el payload tenga los IDs
const payload = {
...data,
company_id: finalCompanyId,
tenant_id: finalCompanyId
};
console.log('[DEBUG] Enviando payload VU:', payload);
return api.put<CustomsBrokerVU>(
`/v1/a76/customs-broker-vu/${brokerKey}?company_id=${finalCompanyId}`,
payload
);
},
updatePersonnel: (brokerKey: string, line: number, data: CustomsBrokerPersonnel, companyId: string) => {

View File

@@ -18,7 +18,7 @@ export interface LineCustoms {
destination_country?: string;
advalorem?: string;
advalorem_numeric?: number;
advalorem_american?: number;
advalorem_american?: number;
advalorem_tlcan?: number;
rate?: string;
depreciation_rate?: number;
@@ -33,7 +33,7 @@ export interface LineFinancials {
unit_cost_mxn?: number;
unit_cost_capture?: number;
unit_cost_commercial_usd?: number;
// Values
value_mc?: number;
value_usd?: number;
@@ -49,7 +49,7 @@ export interface LineQuantities {
line_item_id?: number;
quantity?: number;
unit_of_measure?: string;
// Special quantities
quantity_temp_export?: number;
quantity_returned?: number;
@@ -92,7 +92,7 @@ export interface FaLineItem {
id?: number;
tenant_id?: number;
company_id?: number;
// Asset information (SCAF specific)
asset_number?: string;
asset_photo?: string;
@@ -101,44 +101,46 @@ export interface FaLineItem {
return_import_invoice?: string;
return_import_date?: number;
movement_type_import?: string;
// Cross-references for import repair
search_invoice?: string;
search_line?: number;
// Search type
search_type?: string;
// Subitems
is_subitem?: boolean;
contains_subitems?: boolean;
subitem_number?: number;
// Subitems
is_subitem?: boolean;
contains_subitems?: boolean;
subitem_number?: number;
// Special flags
download?: boolean;
own_equipment?: boolean;
omit_annex31?: boolean;
omit_annex31?: boolean;
// Timestamps
created_at?: string;
updated_at?: string;
}
export interface Item {
id?: number;
invoice_id: number;
id?: number;
invoice_id: number;
line_number: number;
// Identification
part_number?: string;
part_number_id?: number;
component_part_number?: string;
component_part_number_id?: number;
class_id?: number;
identifier?: string;
// Unit of Measure
unit_of_measure?: number;
alternate_unit?: number;
// Permits
permit_number?: string;
page_line?: string;
@@ -151,29 +153,29 @@ export interface Item {
includes_subitems?: boolean;
tax_payment?: boolean;
is_military_mcia?: boolean;
// Payment
payment_method?: string;
igi_amount?: number;
// Additional notes
wildcard_field?: string;
// Computed fields from class_info relation
class_code?: string;
class_description?: string;
// Computed field from unit_of_measure_info relation
unit_of_measure_code?: string;
reference_number?: string;
order?: string;
guide_number?: string;
depreciation_date?: number;
rectification?: number;
warehouse?: string;
location?: string;
created_at?: string;
updated_at?: string;
unit_of_measure_code?: string;
reference_number?: string;
order?: string;
guide_number?: string;
depreciation_date?: number;
rectification?: number;
warehouse?: string;
location?: string;
created_at?: string;
updated_at?: string;
// Nested relations (Singular names to match backend Pydantic models)
customs?: LineCustoms;
@@ -185,86 +187,86 @@ export interface Item {
}
export interface ItemListResponse {
items: Item[];
total: number;
skip: number;
limit: number;
items: Item[];
total: number;
skip: number;
limit: number;
}
export interface CreateItemData extends Omit<Item, 'id' | 'created_at' | 'updated_at'> {
invoice_id: number;
invoice_id: number;
}
export interface UpdateItemData extends Partial<Omit<Item, 'id' | 'invoice_id' | 'created_at' | 'updated_at'>> {}
export interface UpdateItemData extends Partial<Omit<Item, 'id' | 'invoice_id' | 'created_at' | 'updated_at'>> { }
/**
* API para Items
*/
export const itemsApi = {
/**
* Lista todos los items con paginación
*/
list: (companyId: number, skip = 0, limit = 100, invoiceId?: number) => {
const params = new URLSearchParams({
company_id: companyId.toString(),
skip: skip.toString(),
limit: limit.toString()
});
/**
* Lista todos los items con paginación
*/
list: (companyId: number, skip = 0, limit = 100, invoiceId?: number) => {
const params = new URLSearchParams({
company_id: companyId.toString(),
skip: skip.toString(),
limit: limit.toString()
});
if (invoiceId) {
params.append('invoice_id', invoiceId.toString());
}
if (invoiceId) {
params.append('invoice_id', invoiceId.toString());
}
return api.get<ItemListResponse>(`/v1/a76/items/?${params.toString()}`);
},
return api.get<ItemListResponse>(`/v1/a76/items/?${params.toString()}`);
},
/**
* Lista items por invoice ID
*/
listByInvoice: (invoiceId: number, companyId: number) => {
const params = new URLSearchParams({
company_id: companyId.toString()
});
return api.get<ItemListResponse>(`/v1/a76/items/invoice/${invoiceId}/items/?${params.toString()}`);
},
/**
* Lista items por invoice ID
*/
listByInvoice: (invoiceId: number, companyId: number) => {
const params = new URLSearchParams({
company_id: companyId.toString()
});
return api.get<ItemListResponse>(`/v1/a76/items/invoice/${invoiceId}/items/?${params.toString()}`);
},
/**
* Obtiene un item por ID
*/
get: (itemId: number, companyId: number) => {
const params = new URLSearchParams({
company_id: companyId.toString()
});
return api.get<Item>(`/v1/a76/items/${itemId}/?${params.toString()}`);
},
/**
* Obtiene un item por ID
*/
get: (itemId: number, companyId: number) => {
const params = new URLSearchParams({
company_id: companyId.toString()
});
return api.get<Item>(`/v1/a76/items/${itemId}/?${params.toString()}`);
},
/**
* Crea un nuevo item
*/
create: (companyId: number, data: CreateItemData) => {
const params = new URLSearchParams({
company_id: companyId.toString()
});
return api.post<Item>(`/v1/a76/items/?${params.toString()}`, data);
},
/**
* Crea un nuevo item
*/
create: (companyId: number, data: CreateItemData) => {
const params = new URLSearchParams({
company_id: companyId.toString()
});
return api.post<Item>(`/v1/a76/items/?${params.toString()}`, data);
},
/**
* Actualiza un item existente
*/
update: (itemId: number, companyId: number, data: UpdateItemData) => {
const params = new URLSearchParams({
company_id: companyId.toString()
});
return api.put<Item>(`/v1/a76/items/${itemId}/?${params.toString()}`, data);
},
/**
* Actualiza un item existente
*/
update: (itemId: number, companyId: number, data: UpdateItemData) => {
const params = new URLSearchParams({
company_id: companyId.toString()
});
return api.put<Item>(`/v1/a76/items/${itemId}/?${params.toString()}`, data);
},
/**
* Elimina un item
*/
delete: (itemId: number, companyId: number) => {
const params = new URLSearchParams({
company_id: companyId.toString()
});
return api.delete(`/v1/a76/items/${itemId}/?${params.toString()}`);
}
/**
* Elimina un item
*/
delete: (itemId: number, companyId: number) => {
const params = new URLSearchParams({
company_id: companyId.toString()
});
return api.delete(`/v1/a76/items/${itemId}/?${params.toString()}`);
}
};

View File

@@ -33,6 +33,7 @@ export interface PedimentoPayments {
}
export interface PedimentoTransportMeans {
id?: number;
destination?: number | null;
entry_exit?: string | null;
arrival?: string | null;
@@ -40,6 +41,7 @@ export interface PedimentoTransportMeans {
}
export interface PedimentoCustomsOffices {
id?: number;
dispatch_customs?: string | null;
entry_exit_customs?: string | null;
}
@@ -185,6 +187,7 @@ export interface Pedimento {
pedimento_incrementables?: PedimentoIncrementables | null;
pedimento_decrementables?: PedimentoDecrementables | null;
pedimento_indexes?: PedimentoIndexes | null;
pedimento_customs_offices?: PedimentoCustomsOffices | null;
pedimento_config_additional?: PedimentoConfigAdditional | null;
pedimento_config_calculations?: PedimentoConfigCalculations | null;
pedimento_config_surcharges?: PedimentoConfigSurcharges | null;
@@ -226,6 +229,7 @@ export interface CreatePedimentoData {
pedimento_incrementables?: PedimentoIncrementables | null;
pedimento_decrementables?: PedimentoDecrementables | null;
pedimento_indexes?: PedimentoIndexes | null;
pedimento_customs_offices?: PedimentoCustomsOffices | null;
pedimento_config_additional?: PedimentoConfigAdditional | null;
pedimento_config_calculations?: PedimentoConfigCalculations | null;
pedimento_config_surcharges?: PedimentoConfigSurcharges | null;
@@ -260,6 +264,7 @@ export interface UpdatePedimentoData {
pedimento_incrementables?: PedimentoIncrementables | null;
pedimento_decrementables?: PedimentoDecrementables | null;
pedimento_indexes?: PedimentoIndexes | null;
pedimento_customs_offices?: PedimentoCustomsOffices | null;
pedimento_config_additional?: PedimentoConfigAdditional | null;
pedimento_config_calculations?: PedimentoConfigCalculations | null;
pedimento_config_surcharges?: PedimentoConfigSurcharges | null;

View File

@@ -2,10 +2,17 @@ import { api, type ApiResponse } from '$lib/api';
export interface Trailer {
trailer_number: string;
plate_number?: string;
ace_trailer_number?: string;
trailer_type_key?: string;
is_active: boolean;
tenant_id?: string;
seal?: string;
entity_code?: string;
plate_number?: string;
state?: string;
country?: string;
container_key?: string;
is_active?: boolean;
company_id?: number | string;
tenant_id?: number | string;
}
export interface TrailerResponse {
@@ -16,7 +23,7 @@ export interface TrailerResponse {
}
class TrailersApi {
private baseUrl = '/v1/a76/trailers';
private baseUrl = '/v1/a76/transportation/trailers';
async list(
companyId: string | number,
@@ -35,6 +42,27 @@ class TrailersApi {
});
return api.get<Trailer>(`${this.baseUrl}/${id}?${queryParams.toString()}`);
}
async create(data: Trailer, companyId: string | number): Promise<ApiResponse<Trailer>> {
const queryParams = new URLSearchParams({
company_id: companyId.toString()
});
return api.post<Trailer>(`${this.baseUrl}?${queryParams.toString()}`, data);
}
async update(id: string, data: Trailer, companyId: string | number): Promise<ApiResponse<Trailer>> {
const queryParams = new URLSearchParams({
company_id: companyId.toString()
});
return api.put<Trailer>(`${this.baseUrl}/${id}/?${queryParams.toString()}`, data);
}
async delete(id: string, companyId: string | number): Promise<ApiResponse<void>> {
const queryParams = new URLSearchParams({
company_id: companyId.toString()
});
return api.delete<void>(`${this.baseUrl}/${id}?${queryParams.toString()}`);
}
}
export const trailersApi = new TrailersApi();

View File

@@ -2,10 +2,26 @@ import { api, type ApiResponse } from '$lib/api';
export interface Transporter {
transporter_key: string;
name: string;
name?: string;
short_name?: string;
responsible?: string;
rfc?: string;
is_active: boolean;
tenant_id?: string;
streets?: string;
postal_code?: string;
city?: string;
state?: string;
country?: string;
loader_code?: string;
caat_code?: string;
transport_code?: string;
transport_interface_type?: string;
ftp_server?: string;
ftp_user?: string;
ftp_password?: string;
ftp_directory?: string;
filler_code?: string;
company_id?: number | string;
tenant_id?: number | string;
}
export interface TransporterResponse {
@@ -35,6 +51,27 @@ class TransportersApi {
});
return api.get<Transporter>(`${this.baseUrl}/${id}?${queryParams.toString()}`);
}
async create(data: Transporter, companyId: string | number): Promise<ApiResponse<Transporter>> {
const queryParams = new URLSearchParams({
company_id: companyId.toString()
});
return api.post<Transporter>(`${this.baseUrl}?${queryParams.toString()}`, data);
}
async update(id: string, data: Transporter, companyId: string | number): Promise<ApiResponse<Transporter>> {
const queryParams = new URLSearchParams({
company_id: companyId.toString()
});
return api.put<Transporter>(`${this.baseUrl}/${id}/?${queryParams.toString()}`, data);
}
async delete(id: string, companyId: string | number): Promise<ApiResponse<void>> {
const queryParams = new URLSearchParams({
company_id: companyId.toString()
});
return api.delete<void>(`${this.baseUrl}/${id}?${queryParams.toString()}`);
}
}
export const transportersApi = new TransportersApi();

View File

@@ -1,32 +1,84 @@
import { api } from '$lib/api';
import { api, type ApiResponse } from '$lib/api';
export interface Vehicle {
vehicle_key: string;
brand?: string;
plate_number?: string;
description?: string;
ace_vehicle_key?: string;
transporter_key?: string;
transport_identifier?: string;
transport_type?: string;
entity_code?: string;
transponder_number?: string;
dot_number?: string;
plate_number?: string;
city?: string;
state?: string;
country?: string;
seal?: string;
insurance_company_name?: string;
insurance_number?: string;
insurance_amount?: number;
insurance_date?: number;
box_number?: string;
brand?: string;
year?: string;
series?: string;
description?: string;
engine_number?: string;
sct_permission?: string;
color?: string;
container_key?: string;
company_id?: number | string;
tenant_id?: number | string;
}
export interface VehicleListResponse {
export interface VehicleResponse {
items: Vehicle[];
total: number;
page: number;
page_size: number;
}
/**
* API para Vehículos
*/
export const vehiclesApi = {
list: (companyId: string, page = 1, pageSize = 50) => {
return api.get<VehicleListResponse>(
`/v1/a76/transportation/vehicles?company_id=${companyId}&page=${page}&page_size=${pageSize}`
);
},
class VehiclesApi {
private baseUrl = '/v1/a76/transportation/vehicles';
get: (vehicleKey: string, companyId: string) => {
return api.get<Vehicle>(
`/v1/a76/transportation/vehicles/${vehicleKey}?company_id=${companyId}`
);
async list(
companyId: string | number,
params?: Record<string, any>
): Promise<ApiResponse<VehicleResponse>> {
const queryParams = new URLSearchParams({
company_id: companyId.toString(),
...params
});
return api.get<VehicleResponse>(`${this.baseUrl}?${queryParams.toString()}`);
}
};
async get(id: string, companyId: string | number): Promise<ApiResponse<Vehicle>> {
const queryParams = new URLSearchParams({
company_id: companyId.toString()
});
return api.get<Vehicle>(`${this.baseUrl}/${id}?${queryParams.toString()}`);
}
async create(data: Vehicle, companyId: string | number): Promise<ApiResponse<Vehicle>> {
const queryParams = new URLSearchParams({
company_id: companyId.toString()
});
return api.post<Vehicle>(`${this.baseUrl}?${queryParams.toString()}`, data);
}
async update(id: string, data: Vehicle, companyId: string | number): Promise<ApiResponse<Vehicle>> {
const queryParams = new URLSearchParams({
company_id: companyId.toString()
});
return api.put<Vehicle>(`${this.baseUrl}/${id}/?${queryParams.toString()}`, data);
}
async delete(id: string, companyId: string | number): Promise<ApiResponse<void>> {
const queryParams = new URLSearchParams({
company_id: companyId.toString()
});
return api.delete<void>(`${this.baseUrl}/${id}?${queryParams.toString()}`);
}
}
export const vehiclesApi = new VehiclesApi();

View File

@@ -20,7 +20,7 @@
} from 'lucide-svelte';
import { toast } from 'svelte-sonner';
import type { Invoice } from '$lib/api/dashboard/a76/invoices';
import { itemsApi, type Item } from '$lib/api/dashboard/a76/items';
import { itemsApi, type Item as InvoiceItem } from '$lib/api/dashboard/a76/items';
import { companyStore } from '$lib/stores/company.svelte';
import ItemSheetFa from './fa/item-sheet-fa.svelte';
import ItemSheetInv from './inv/item-sheet-inv.svelte';
@@ -43,7 +43,7 @@
} = $props();
// 1. Core State
let items = $state<Item[]>([]);
let items = $state<InvoiceItem[]>([]);
let displayedItems = $state<any[]>([]);
let imported = $state(0);
let net_weight = $state(0);
@@ -129,9 +129,9 @@
let showItemSheet = $state(false);
let isEditMode = $state(false);
let showDeleteDialog = $state(false);
let selectedItem = $state<Item | null>(null);
let originalItemData = $state<Partial<Item> | null>(null);
let editingItem = $state<Partial<Item>>({
let selectedItem = $state<InvoiceItem | null>(null);
let originalItemData = $state<Partial<InvoiceItem> | null>(null);
let editingItem = $state<Partial<InvoiceItem>>({
invoice_id: undefined,
reference_number: '',
order: '',
@@ -400,7 +400,7 @@
return cleanLineData({ ...rest });
}
function cloneItemForPreset(item: Item) {
function cloneItemForPreset(item: InvoiceItem) {
const { id, tenant_id, company_id, created_at, updated_at, temp_id, ...rest } = item as any;
return {
...sanitizeLineForPreset(rest),
@@ -502,7 +502,7 @@
isSavingPreset = true;
try {
// We group everything as items for the template
const lines = builderItems.map((item: Item, idx: number) => {
const lines = builderItems.map((item: InvoiceItem, idx: number) => {
return {
...cleanLineData(item),
line_number: item.line_number || idx + 1, // Ensure line_number is present
@@ -552,7 +552,7 @@
}
// Enrich item with descriptive data for display
async function enrichItemData(item: Partial<Item>) {
async function enrichItemData(item: Partial<InvoiceItem>) {
if (!item || !activeCompanyId) return;
// Load class data
@@ -697,7 +697,7 @@
}
// Normalize numeric values from strings to numbers
function normalizeItemData(item: Partial<Item>): Partial<Item> {
function normalizeItemData(item: Partial<InvoiceItem>): Partial<InvoiceItem> {
if (item) {
const normalizedItem = { ...item };

View File

@@ -0,0 +1,226 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import * as Dialog from '$lib/components/ui/dialog';
import * as Table from '$lib/components/ui/table';
import { Search, Loader2, Factory } from 'lucide-svelte';
import { sectorsApi, type Sector } from '$lib/api/dashboard/reference_data/sectors';
import { toast } from 'svelte-sonner';
// --- PROPS ---
let {
open = $bindable(false),
onSelect
}: {
open: boolean;
onSelect: (item: Sector) => void;
} = $props();
// --- ESTADO ---
let items = $state<Sector[]>([]);
let loading = $state(false);
let loadingMore = $state(false);
let searchTerm = $state('');
let previousSearchTerm = '';
let page = $state(1);
let pageSize = 50;
let hasMore = $state(true);
let totalItems = $state(0);
let observer: IntersectionObserver | null = null;
let bottomSentinel: HTMLElement | null = $state(null);
let searchTimeout: any;
let isInitialized = false;
// Cargar datos iniciales al abrir
$effect(() => {
if (open && !isInitialized) {
isInitialized = true;
previousSearchTerm = searchTerm;
resetAndLoad();
} else if (!open) {
isInitialized = false;
}
});
// Manejar búsqueda con debouncing
$effect(() => {
const term = searchTerm;
if (isInitialized && term !== previousSearchTerm) {
if (searchTimeout) clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
previousSearchTerm = term;
resetAndLoad();
}, 500);
}
});
// Configurar IntersectionObserver para infinite scroll
$effect(() => {
if (bottomSentinel && hasMore && !loading && !loadingMore && open) {
if (observer) observer.disconnect();
observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && hasMore && !loading && !loadingMore) {
loadMore();
}
},
{ threshold: 0.1 }
);
observer.observe(bottomSentinel);
}
return () => {
if (observer) observer.disconnect();
};
});
async function resetAndLoad() {
page = 1;
items = [];
hasMore = true;
await loadSectors(true);
}
async function loadMore() {
if (!hasMore || loading || loadingMore) return;
page += 1;
await loadSectors(false);
}
async function loadSectors(isInitial: boolean) {
if (isInitial) {
loading = true;
} else {
loadingMore = true;
}
try {
const response = await sectorsApi.list(page, pageSize);
if (response.error) {
toast.error(`Error: ${response.error}`);
hasMore = false;
return;
}
let newItems = response.data?.items || [];
totalItems = response.data?.total || 0;
if (searchTerm) {
newItems = newItems.filter(
(item) =>
item.description.toLowerCase().includes(searchTerm.toLowerCase()) ||
item.key.toLowerCase().includes(searchTerm.toLowerCase())
);
}
if (isInitial) {
items = newItems;
} else {
items = [...items, ...newItems];
}
hasMore = items.length < totalItems && newItems.length > 0;
} catch (e: any) {
console.error('Error loading sectors:', e);
toast.error('Error al conectar con el servidor');
hasMore = false;
} finally {
loading = false;
loadingMore = false;
}
}
function handleSelect(item: Sector) {
if (onSelect) onSelect(item);
open = false;
}
</script>
<Dialog.Root bind:open>
<Dialog.Content class="flex max-h-[90vh] flex-col sm:max-w-[800px]">
<Dialog.Header>
<Dialog.Title>Seleccionar Sector PROSEC</Dialog.Title>
<Dialog.Description>
Seleccione el sector del catálogo. Escrolea para ver más.
</Dialog.Description>
</Dialog.Header>
<div class="relative my-2 w-full">
<Search class="absolute top-2.5 left-2.5 h-4 w-4 text-muted-foreground" />
<Input
type="search"
placeholder="Filtrar por clave o descripción..."
class="pl-9"
bind:value={searchTerm}
/>
</div>
<div class="min-h-[300px] flex-1 overflow-y-auto rounded-md border">
{#if loading && items.length === 0}
<div class="flex h-48 flex-col items-center justify-center gap-2 text-muted-foreground">
<Loader2 class="h-8 w-8 animate-spin text-primary" />
<p>Cargando catálogo...</p>
</div>
{:else if items.length === 0}
<div class="flex h-48 flex-col items-center justify-center text-muted-foreground">
<p>No se encontraron sectores.</p>
</div>
{:else}
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head class="w-[100px]">Clave</Table.Head>
<Table.Head>Descripción</Table.Head>
<Table.Head class="w-[100px]">Autorizado</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each items as item}
<Table.Row
class="cursor-pointer transition-colors hover:bg-accent/50"
onclick={() => handleSelect(item)}
>
<Table.Cell>
<div class="flex items-center gap-1">
<Factory class="h-3 w-3 text-orange-500" />
<span class="font-mono text-xs font-bold">
{item.key}
</span>
</div>
</Table.Cell>
<Table.Cell class="text-sm font-medium">
{item.description}
</Table.Cell>
<Table.Cell>
<span
class="rounded-full px-2 py-0.5 text-xs {item.authorized
? 'bg-green-100 text-green-700'
: 'bg-red-100 text-red-700'}"
>
{item.authorized ? 'Sí' : 'No'}
</span>
</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
<div bind:this={bottomSentinel} class="flex h-10 items-center justify-center">
{#if loadingMore}
<Loader2 class="h-5 w-5 animate-spin text-muted-foreground" />
{/if}
</div>
{/if}
</div>
<Dialog.Footer>
<div class="mr-auto self-center text-xs text-muted-foreground">
{items.length} de {totalItems} registros
</div>
<Button variant="outline" onclick={() => (open = false)}>Cancelar</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>

View File

@@ -0,0 +1,224 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import * as Dialog from '$lib/components/ui/dialog';
import * as Table from '$lib/components/ui/table';
import { Search, Loader2, MapPin } from 'lucide-svelte';
import { statesApi, type State } from '$lib/api/dashboard/reference_data/states';
import { toast } from 'svelte-sonner';
// --- PROPS ---
let {
open = $bindable(false),
onSelect
}: {
open: boolean;
onSelect: (item: State) => void;
} = $props();
// --- ESTADO ---
let items = $state<State[]>([]);
let loading = $state(false);
let loadingMore = $state(false);
let searchTerm = $state('');
let previousSearchTerm = '';
let page = $state(1);
let pageSize = 50;
let hasMore = $state(true);
let totalItems = $state(0);
let observer: IntersectionObserver | null = null;
let bottomSentinel: HTMLElement | null = $state(null);
let searchTimeout: any;
let isInitialized = false;
// Cargar datos iniciales al abrir
$effect(() => {
if (open && !isInitialized) {
isInitialized = true;
previousSearchTerm = searchTerm;
resetAndLoad();
} else if (!open) {
isInitialized = false;
}
});
// Manejar búsqueda con debouncing
$effect(() => {
const term = searchTerm;
if (isInitialized && term !== previousSearchTerm) {
if (searchTimeout) clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
previousSearchTerm = term;
resetAndLoad();
}, 500);
}
});
// Configurar IntersectionObserver para infinite scroll
$effect(() => {
if (bottomSentinel && hasMore && !loading && !loadingMore && open) {
if (observer) observer.disconnect();
observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && hasMore && !loading && !loadingMore) {
loadMore();
}
},
{ threshold: 0.1 }
);
observer.observe(bottomSentinel);
}
return () => {
if (observer) observer.disconnect();
};
});
async function resetAndLoad() {
page = 1;
items = [];
hasMore = true;
await loadStates(true);
}
async function loadMore() {
if (!hasMore || loading || loadingMore) return;
page += 1;
await loadStates(false);
}
async function loadStates(isInitial: boolean) {
if (isInitial) {
loading = true;
} else {
loadingMore = true;
}
try {
// Note: statesApi.list takes page, pageSize, and searchTerm?
// Wait, let me check statesApi.list signature again.
// It only takes page and pageSize! I need to check if it supports search.
const response = await statesApi.list(page, pageSize);
if (response.error) {
toast.error(`Error: ${response.error}`);
hasMore = false;
return;
}
// Local filtering if search term exists (temporary workaround if API doesn't support it)
let newItems = response.data?.items || [];
totalItems = response.data?.total || 0;
if (searchTerm) {
newItems = newItems.filter(
(item) =>
item.description.toLowerCase().includes(searchTerm.toLowerCase()) ||
item.m3_key.toLowerCase().includes(searchTerm.toLowerCase())
);
}
if (isInitial) {
items = newItems;
} else {
items = [...items, ...newItems];
}
hasMore = items.length < totalItems && newItems.length > 0;
} catch (e: any) {
console.error('Error loading states:', e);
toast.error('Error al conectar con el servidor');
hasMore = false;
} finally {
loading = false;
loadingMore = false;
}
}
function handleSelect(item: State) {
if (onSelect) onSelect(item);
open = false;
}
</script>
<Dialog.Root bind:open>
<Dialog.Content class="flex max-h-[90vh] flex-col sm:max-w-[800px]">
<Dialog.Header>
<Dialog.Title>Seleccionar Estado</Dialog.Title>
<Dialog.Description>
Seleccione el estado del catálogo. Escrolea para ver más.
</Dialog.Description>
</Dialog.Header>
<div class="relative my-2 w-full">
<Search class="absolute top-2.5 left-2.5 h-4 w-4 text-muted-foreground" />
<Input
type="search"
placeholder="Filtrar por clave o descripción..."
class="pl-9"
bind:value={searchTerm}
/>
</div>
<div class="min-h-[300px] flex-1 overflow-y-auto rounded-md border">
{#if loading && items.length === 0}
<div class="flex h-48 flex-col items-center justify-center gap-2 text-muted-foreground">
<Loader2 class="h-8 w-8 animate-spin text-primary" />
<p>Cargando catálogo...</p>
</div>
{:else if items.length === 0}
<div class="flex h-48 flex-col items-center justify-center text-muted-foreground">
<p>No se encontraron estados.</p>
</div>
{:else}
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head class="w-[100px]">Clave M3</Table.Head>
<Table.Head>Descripción</Table.Head>
<Table.Head class="w-[80px]">MEX</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each items as item}
<Table.Row
class="cursor-pointer transition-colors hover:bg-accent/50"
onclick={() => handleSelect(item)}
>
<Table.Cell>
<div class="flex items-center gap-1">
<MapPin class="h-3 w-3 text-red-500" />
<span class="font-mono text-xs font-bold">
{item.m3_key}
</span>
</div>
</Table.Cell>
<Table.Cell class="text-sm font-medium">
{item.description}
</Table.Cell>
<Table.Cell class="font-mono text-xs">
{item.mex_key || '-'}
</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
<div bind:this={bottomSentinel} class="flex h-10 items-center justify-center">
{#if loadingMore}
<Loader2 class="h-5 w-5 animate-spin text-muted-foreground" />
{/if}
</div>
{/if}
</div>
<Dialog.Footer>
<div class="mr-auto self-center text-xs text-muted-foreground">
{items.length} de {totalItems} registros
</div>
<Button variant="outline" onclick={() => (open = false)}>Cancelar</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>

View File

@@ -0,0 +1,64 @@
/**
* Definición de columnas para la tabla de Trailers
*/
import type { Trailer } from '$lib/api/dashboard/a76/trailers';
import type { ColumnDef } from '@tanstack/table-core';
import { renderComponent } from '$lib/components/ui/data-table';
import DataTableActions from './data-table-actions.svelte';
export function createColumns(onSuccess?: () => void): ColumnDef<Trailer>[] {
return [
{
accessorKey: 'trailer_number',
header: 'Número de Trailer',
cell: ({ row }) => {
return row.original.trailer_number;
}
},
{
accessorKey: 'plate_number',
header: 'Placas',
cell: ({ row }) => {
return row.original.plate_number || '-';
}
},
{
accessorKey: 'trailer_type_key',
header: 'Tipo de Trailer',
cell: ({ row }) => {
return row.original.trailer_type_key || '-';
}
},
{
accessorKey: 'container_key',
header: 'Contenedor',
cell: ({ row }) => {
return row.original.container_key || '-';
}
},
{
accessorKey: 'state',
header: 'Estado',
cell: ({ row }) => {
return row.original.state || '-';
}
},
{
accessorKey: 'country',
header: 'País',
cell: ({ row }) => {
return row.original.country || '-';
}
},
{
id: 'actions',
header: 'Acciones',
cell: ({ row }) => {
return renderComponent(DataTableActions, {
item: row.original,
onSuccess
});
}
}
];
}

View File

@@ -0,0 +1,207 @@
<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 { trailersApi, type Trailer } from '$lib/api/dashboard/a76/trailers';
import { companyStore } from '$lib/stores/company.svelte';
let {
open = $bindable(false),
item = null,
onSuccess
}: {
open: boolean;
item?: Trailer | null;
onSuccess?: () => void;
} = $props();
const isEdit = $derived(!!item);
const title = $derived(isEdit ? 'Editar Trailer' : 'Nuevo Trailer');
let formData = $state<Trailer>({
trailer_number: '',
ace_trailer_number: '',
trailer_type_key: '',
seal: '',
entity_code: '',
plate_number: '',
state: '',
country: '',
container_key: ''
});
let loading = $state(false);
let error = $state<string | null>(null);
$effect(() => {
if (item) {
formData = { ...item };
} else {
formData = {
trailer_number: '',
ace_trailer_number: '',
trailer_type_key: '',
seal: '',
entity_code: '',
plate_number: '',
state: '',
country: '',
container_key: ''
};
}
});
async function handleSubmit() {
error = null;
loading = true;
try {
const companyId = companyStore.activeCompany?.id;
if (!companyId) {
throw new Error('No hay una compañía seleccionada');
}
if (!formData.trailer_number.trim()) {
throw new Error('El número de trailer es requerido');
}
let response;
if (isEdit && item) {
response = await trailersApi.update(item.trailer_number, formData, companyId);
} else {
response = await trailersApi.create(formData, companyId);
}
if (response.error) {
throw new Error(response.error);
}
open = false;
if (onSuccess) {
onSuccess();
}
} catch (e) {
error = e instanceof Error ? e.message : 'Error al guardar el trailer';
} finally {
loading = false;
}
}
function handleCancel() {
open = false;
error = null;
}
</script>
<Dialog.Root bind:open>
<Dialog.Content class="max-h-[90vh] overflow-y-auto sm:max-w-5xl">
<Dialog.Header>
<Dialog.Title>{title}</Dialog.Title>
<Dialog.Description>
{isEdit
? 'Modifica los datos del trailer'
: 'Completa los datos para crear un nuevo trailer'}
</Dialog.Description>
</Dialog.Header>
<form
onsubmit={(e) => {
e.preventDefault();
handleSubmit();
}}
class="space-y-6"
>
{#if error}
<div class="rounded-md bg-destructive/15 p-3 text-sm text-destructive">
{error}
</div>
{/if}
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="grid gap-2">
<Label for="trailer_number"
>Número de Trailer <span class="text-destructive">*</span></Label
>
<Input
id="trailer_number"
bind:value={formData.trailer_number}
disabled={isEdit}
required
maxlength={20}
/>
</div>
<div class="grid gap-2">
<Label for="plate_number">Placas</Label>
<Input id="plate_number" bind:value={formData.plate_number} maxlength={17} />
</div>
<div class="grid gap-2">
<Label for="ace_trailer_number">Número Trailer ACE</Label>
<Input id="ace_trailer_number" bind:value={formData.ace_trailer_number} maxlength={10} />
</div>
<div class="grid gap-2">
<Label for="trailer_type_key">Tipo de Trailer (Clave)</Label>
<Input
id="trailer_type_key"
bind:value={formData.trailer_type_key}
maxlength={2}
placeholder="2 car."
/>
</div>
<div class="grid gap-2">
<Label for="container_key">Contenedor (Clave)</Label>
<Input
id="container_key"
bind:value={formData.container_key}
maxlength={3}
placeholder="3 car."
/>
</div>
<div class="grid gap-2">
<Label for="seal">Sello</Label>
<Input id="seal" bind:value={formData.seal} maxlength={15} />
</div>
<div class="grid gap-2">
<Label for="entity_code">Código Entidad</Label>
<Input
id="entity_code"
bind:value={formData.entity_code}
maxlength={1}
placeholder="1 car."
/>
</div>
<div class="grid grid-cols-2 gap-4">
<div class="grid gap-2">
<Label for="state">Estado</Label>
<Input id="state" bind:value={formData.state} maxlength={30} placeholder="Ej: TX" />
</div>
<div class="grid gap-2">
<Label for="country">País</Label>
<Input
id="country"
bind:value={formData.country}
maxlength={3}
placeholder="MEX / USA"
/>
</div>
</div>
</div>
<Dialog.Footer>
<Button type="button" variant="outline" onclick={handleCancel} disabled={loading}>
Cancelar
</Button>
<Button type="submit" disabled={loading}>
{loading ? 'Guardando...' : isEdit ? 'Actualizar' : 'Crear'}
</Button>
</Dialog.Footer>
</form>
</Dialog.Content>
</Dialog.Root>

View File

@@ -0,0 +1,111 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import { trailersApi, type Trailer } from '$lib/api/dashboard/a76/trailers';
import { companyStore } from '$lib/stores/company.svelte';
import { EllipsisVertical, Pencil, LoaderCircle, Trash2 } from 'lucide-svelte';
import CreateEditDialog from './create-edit-dialog.svelte';
let {
item,
onSuccess
}: {
item: Trailer;
onSuccess?: () => void;
} = $props();
let loading = $state(false);
let error = $state<string | null>(null);
let dialogOpen = $state(false);
let selectedItem = $state<Trailer | null>(null);
async function handleDelete() {
if (
!confirm(
`¿Estás seguro de eliminar el trailer "${item.trailer_number}"?\n\nNota: No se puede eliminar si tiene registros relacionados.`
)
) {
return;
}
if (!companyStore.activeCompany) {
alert('❌ Error: No hay una compañía seleccionada');
return;
}
loading = true;
error = null;
try {
const response = await trailersApi.delete(item.trailer_number, companyStore.activeCompany.id);
if (response.error) {
if (response.status === 401) {
error = 'Sesión expirada. Recargando página...';
setTimeout(() => {
window.location.reload();
}, 1500);
} else {
error = response.error;
alert(`❌ Error al eliminar:\n\n${response.error}`);
}
return;
}
// Éxito
alert(`✅ Trailer "${item.trailer_number}" eliminado correctamente`);
if (onSuccess) {
onSuccess();
}
} catch (e) {
error = e instanceof Error ? e.message : 'Error al eliminar';
alert(`❌ Error: ${error}`);
console.error('Error deleting:', e);
} finally {
loading = false;
}
}
function handleEdit() {
selectedItem = item;
dialogOpen = true;
}
function handleDialogSuccess() {
dialogOpen = false;
selectedItem = null;
if (onSuccess) {
onSuccess();
}
}
</script>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
{#snippet child({ props })}
<Button {...props} variant="ghost" size="icon" class="relative size-8 p-0">
<span class="sr-only">Abrir menú</span>
<EllipsisVertical size={16} />
</Button>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end" class="w-[160px]">
<DropdownMenu.Label>Acciones</DropdownMenu.Label>
<DropdownMenu.Separator />
<DropdownMenu.Item onclick={handleEdit}>
<Pencil size={16} class="mr-2" />
Editar
</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Item onclick={handleDelete} class="text-destructive" disabled={loading}>
{#if loading}
<LoaderCircle class="mr-2 h-4 w-4 animate-spin" />
{:else}
<Trash2 size={16} class="mr-2" />
{/if}
Eliminar
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
<CreateEditDialog bind:open={dialogOpen} item={selectedItem} onSuccess={handleDialogSuccess} />

View File

@@ -0,0 +1,99 @@
<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;
},
get columns() {
return columns;
},
getCoreRowModel: getCoreRowModel(),
manualPagination: true,
get pageCount() {
return 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 bg-card">
<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,306 @@
<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 { transportersApi, type Transporter } from '$lib/api/dashboard/a76/transporters';
import { companyStore } from '$lib/stores/company.svelte';
let {
open = $bindable(false),
item = null,
onSuccess
}: {
open: boolean;
item?: Transporter | null;
onSuccess?: () => void;
} = $props();
// Determinar si es modo edición o creación
const isEdit = $derived(!!item);
const title = $derived(isEdit ? 'Editar Transportista' : 'Nuevo Transportista');
// Estado del formulario
let formData = $state<Transporter>({
transporter_key: '',
name: '',
short_name: '',
responsible: '',
rfc: '',
streets: '',
postal_code: '',
city: '',
state: '',
country: '',
loader_code: '',
caat_code: '',
transport_code: '',
transport_interface_type: '',
ftp_server: '',
ftp_user: '',
ftp_password: '',
ftp_directory: '',
filler_code: ''
});
let loading = $state(false);
let error = $state<string | null>(null);
// Resetear formulario cuando cambia el item
$effect(() => {
if (item) {
formData = { ...item };
} else {
formData = {
transporter_key: '',
name: '',
short_name: '',
responsible: '',
rfc: '',
streets: '',
postal_code: '',
city: '',
state: '',
country: '',
loader_code: '',
caat_code: '',
transport_code: '',
transport_interface_type: '',
ftp_server: '',
ftp_user: '',
ftp_password: '',
ftp_directory: '',
filler_code: ''
};
}
});
async function handleSubmit() {
error = null;
loading = true;
try {
const companyId = companyStore.activeCompany?.id;
if (!companyId) {
throw new Error('No hay una compañía seleccionada');
}
// Validación básica
if (!formData.transporter_key.trim()) {
throw new Error('La clave es requerida');
}
let response;
if (isEdit && item) {
response = await transportersApi.update(item.transporter_key, formData, companyId);
} else {
response = await transportersApi.create(formData, companyId);
}
if (response.error) {
throw new Error(response.error);
}
// Cerrar diálogo y notificar éxito
open = false;
if (onSuccess) {
onSuccess();
}
} catch (e) {
error = e instanceof Error ? e.message : 'Error al guardar el transportista';
} finally {
loading = false;
}
}
function handleCancel() {
open = false;
error = null;
}
</script>
<Dialog.Root bind:open>
<Dialog.Content class="max-h-[90vh] overflow-y-auto sm:max-w-5xl">
<Dialog.Header>
<Dialog.Title>{title}</Dialog.Title>
<Dialog.Description>
{isEdit
? 'Modifica los datos del transportista'
: 'Completa los datos para crear un nuevo transportista'}
</Dialog.Description>
</Dialog.Header>
<form
onsubmit={(e) => {
e.preventDefault();
handleSubmit();
}}
class="space-y-6"
>
{#if error}
<div class="rounded-md bg-destructive/15 p-3 text-sm text-destructive">
{error}
</div>
{/if}
<div class="grid grid-cols-1 gap-6 md:grid-cols-2">
<!-- Información General -->
<section class="space-y-4">
<h3 class="border-b pb-2 text-sm font-semibold">Información General</h3>
<div class="grid gap-2">
<Label for="transporter_key">Clave <span class="text-destructive">*</span></Label>
<Input
id="transporter_key"
bind:value={formData.transporter_key}
disabled={isEdit}
required
maxlength={23}
/>
</div>
<div class="grid gap-2">
<Label for="name">Nombre / Razón Social</Label>
<Input id="name" bind:value={formData.name} maxlength={256} />
</div>
<div class="grid gap-2">
<Label for="short_name">Nombre Corto</Label>
<Input
id="short_name"
bind:value={formData.short_name}
maxlength={10}
placeholder="Máx. 10 car."
/>
</div>
<div class="grid gap-2">
<Label for="rfc">RFC</Label>
<Input id="rfc" bind:value={formData.rfc} />
</div>
<div class="grid gap-2">
<Label for="responsible">Responsable</Label>
<Input id="responsible" bind:value={formData.responsible} />
</div>
</section>
<!-- Códigos y Transporte -->
<section class="space-y-4">
<h3 class="border-b pb-2 text-sm font-semibold">Códigos de Transporte</h3>
<div class="grid gap-2">
<Label for="caat_code">Código CAAT</Label>
<Input id="caat_code" bind:value={formData.caat_code} />
</div>
<div class="grid gap-2">
<Label for="transport_code">Código de Transporte</Label>
<Input
id="transport_code"
bind:value={formData.transport_code}
maxlength={8}
placeholder="Máx. 8 car."
/>
</div>
<div class="grid gap-2">
<Label for="loader_code">Código Cargador</Label>
<Input
id="loader_code"
bind:value={formData.loader_code}
maxlength={9}
placeholder="Máx. 9 car."
/>
</div>
<div class="grid gap-2">
<Label for="transport_interface_type">Tipo Interfaz</Label>
<Input
id="transport_interface_type"
bind:value={formData.transport_interface_type}
maxlength={20}
/>
</div>
<div class="grid gap-2">
<Label for="filler_code">Código Relleno</Label>
<Input id="filler_code" bind:value={formData.filler_code} maxlength={20} />
</div>
</section>
<!-- Dirección -->
<section class="space-y-4">
<h3 class="border-b pb-2 text-sm font-semibold">Dirección</h3>
<div class="grid gap-2">
<Label for="streets">Calle y Número</Label>
<Input id="streets" bind:value={formData.streets} />
</div>
<div class="grid grid-cols-2 gap-4">
<div class="grid gap-2">
<Label for="city">Ciudad</Label>
<Input id="city" bind:value={formData.city} maxlength={30} />
</div>
<div class="grid gap-2">
<Label for="state">Estado</Label>
<Input id="state" bind:value={formData.state} maxlength={30} />
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div class="grid gap-2">
<Label for="country">País</Label>
<Input
id="country"
bind:value={formData.country}
maxlength={3}
placeholder="MEX / USA"
/>
</div>
<div class="grid gap-2">
<Label for="postal_code">C.P.</Label>
<Input id="postal_code" bind:value={formData.postal_code} />
</div>
</div>
</section>
<!-- Configuración FTP -->
<section class="space-y-4">
<h3 class="border-b pb-2 text-sm font-semibold">Configuración FTP</h3>
<div class="grid gap-2">
<Label for="ftp_server">Servidor FTP</Label>
<Input id="ftp_server" bind:value={formData.ftp_server} />
</div>
<div class="grid grid-cols-2 gap-4">
<div class="grid gap-2">
<Label for="ftp_user">Usuario</Label>
<Input id="ftp_user" bind:value={formData.ftp_user} />
</div>
<div class="grid gap-2">
<Label for="ftp_password">Contraseña</Label>
<Input id="ftp_password" type="password" bind:value={formData.ftp_password} />
</div>
</div>
<div class="grid gap-2">
<Label for="ftp_directory">Directorio</Label>
<Input id="ftp_directory" bind:value={formData.ftp_directory} />
</div>
</section>
</div>
<Dialog.Footer>
<Button type="button" variant="outline" onclick={handleCancel} disabled={loading}>
Cancelar
</Button>
<Button type="submit" disabled={loading}>
{loading ? 'Guardando...' : isEdit ? 'Actualizar' : 'Crear'}
</Button>
</Dialog.Footer>
</form>
</Dialog.Content>
</Dialog.Root>

View File

@@ -0,0 +1,114 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import { transportersApi, type Transporter } from '$lib/api/dashboard/a76/transporters';
import { companyStore } from '$lib/stores/company.svelte';
import { EllipsisVertical, Pencil, LoaderCircle, Trash2 } from 'lucide-svelte';
import CreateEditDialog from './create-edit-dialog.svelte';
let {
item,
onSuccess
}: {
item: Transporter;
onSuccess?: () => void;
} = $props();
let loading = $state(false);
let error = $state<string | null>(null);
let dialogOpen = $state(false);
let selectedItem = $state<Transporter | null>(null);
async function handleDelete() {
if (
!confirm(
`¿Estás seguro de eliminar el transportista "${item.transporter_key}"?\n\nNota: No se puede eliminar si tiene registros relacionados.`
)
) {
return;
}
if (!companyStore.activeCompany) {
alert('❌ Error: No hay una compañía seleccionada');
return;
}
loading = true;
error = null;
try {
const response = await transportersApi.delete(
item.transporter_key,
companyStore.activeCompany.id
);
if (response.error) {
if (response.status === 401) {
error = 'Sesión expirada. Recargando página...';
setTimeout(() => {
window.location.reload();
}, 1500);
} else {
error = response.error;
alert(`❌ Error al eliminar:\n\n${response.error}`);
}
return;
}
// Éxito
alert(`✅ Transportista "${item.transporter_key}" eliminado correctamente`);
if (onSuccess) {
onSuccess();
}
} catch (e) {
error = e instanceof Error ? e.message : 'Error al eliminar';
alert(`❌ Error: ${error}`);
console.error('Error deleting:', e);
} finally {
loading = false;
}
}
function handleEdit() {
selectedItem = item;
dialogOpen = true;
}
function handleDialogSuccess() {
dialogOpen = false;
selectedItem = null;
if (onSuccess) {
onSuccess();
}
}
</script>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
{#snippet child({ props })}
<Button {...props} variant="ghost" size="icon" class="relative size-8 p-0">
<span class="sr-only">Abrir menú</span>
<EllipsisVertical size={16} />
</Button>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end" class="w-[160px]">
<DropdownMenu.Label>Acciones</DropdownMenu.Label>
<DropdownMenu.Separator />
<DropdownMenu.Item onclick={handleEdit}>
<Pencil size={16} class="mr-2" />
Editar
</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Item onclick={handleDelete} class="text-destructive" disabled={loading}>
{#if loading}
<LoaderCircle class="mr-2 h-4 w-4 animate-spin" />
{:else}
<Trash2 size={16} class="mr-2" />
{/if}
Eliminar
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
<CreateEditDialog bind:open={dialogOpen} item={selectedItem} onSuccess={handleDialogSuccess} />

View File

@@ -0,0 +1,99 @@
<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;
},
get columns() {
return columns;
},
getCoreRowModel: getCoreRowModel(),
manualPagination: true,
get pageCount() {
return 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 bg-card">
<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,64 @@
/**
* Definición de columnas para la tabla de Transportistas
*/
import type { Transporter } from '$lib/api/dashboard/a76/transporters';
import type { ColumnDef } from '@tanstack/table-core';
import { renderComponent } from '$lib/components/ui/data-table';
import DataTableActions from './data-table-actions.svelte';
export function createColumns(onSuccess?: () => void): ColumnDef<Transporter>[] {
return [
{
accessorKey: 'transporter_key',
header: 'Clave',
cell: ({ row }) => {
return row.original.transporter_key;
}
},
{
accessorKey: 'name',
header: 'Nombre',
cell: ({ row }) => {
return row.original.name || '-';
}
},
{
accessorKey: 'short_name',
header: 'Nombre Corto',
cell: ({ row }) => {
return row.original.short_name || '-';
}
},
{
accessorKey: 'rfc',
header: 'RFC',
cell: ({ row }) => {
return row.original.rfc || '-';
}
},
{
accessorKey: 'caat_code',
header: 'CAAT',
cell: ({ row }) => {
return row.original.caat_code || '-';
}
},
{
accessorKey: 'transport_code',
header: 'Código Transporte',
cell: ({ row }) => {
return row.original.transport_code || '-';
}
},
{
id: 'actions',
header: 'Acciones',
cell: ({ row }) => {
return renderComponent(DataTableActions, {
item: row.original,
onSuccess
});
}
}
];
}

View File

@@ -0,0 +1,64 @@
/**
* Definición de columnas para la tabla de Vehículos
*/
import type { Vehicle } from '$lib/api/dashboard/a76/vehicles';
import type { ColumnDef } from '@tanstack/table-core';
import { renderComponent } from '$lib/components/ui/data-table';
import DataTableActions from './data-table-actions.svelte';
export function createColumns(onSuccess?: () => void): ColumnDef<Vehicle>[] {
return [
{
accessorKey: 'vehicle_key',
header: 'Clave',
cell: ({ row }) => {
return row.original.vehicle_key;
}
},
{
accessorKey: 'brand',
header: 'Marca',
cell: ({ row }) => {
return row.original.brand || '-';
}
},
{
accessorKey: 'year',
header: 'Año',
cell: ({ row }) => {
return row.original.year || '-';
}
},
{
accessorKey: 'plate_number',
header: 'Placas',
cell: ({ row }) => {
return row.original.plate_number || '-';
}
},
{
accessorKey: 'transporter_key',
header: 'Transportista',
cell: ({ row }) => {
return row.original.transporter_key || '-';
}
},
{
accessorKey: 'transport_type',
header: 'Tipo Transporte',
cell: ({ row }) => {
return row.original.transport_type || '-';
}
},
{
id: 'actions',
header: 'Acciones',
cell: ({ row }) => {
return renderComponent(DataTableActions, {
item: row.original,
onSuccess
});
}
}
];
}

View File

@@ -0,0 +1,317 @@
<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 { vehiclesApi, type Vehicle } from '$lib/api/dashboard/a76/vehicles';
import { companyStore } from '$lib/stores/company.svelte';
let {
open = $bindable(false),
item = null,
onSuccess
}: {
open: boolean;
item?: Vehicle | null;
onSuccess?: () => void;
} = $props();
// Determinar si es modo edición o creación
const isEdit = $derived(!!item);
const title = $derived(isEdit ? 'Editar Vehículo' : 'Nuevo Vehículo');
// Estado del formulario
let formData = $state<Vehicle>({
vehicle_key: '',
ace_vehicle_key: '',
transporter_key: '',
transport_identifier: '',
transport_type: '',
entity_code: '',
transponder_number: '',
dot_number: '',
plate_number: '',
city: '',
state: '',
country: '',
seal: '',
insurance_company_name: '',
insurance_number: '',
insurance_amount: undefined,
insurance_date: undefined,
box_number: '',
brand: '',
year: '',
series: '',
description: '',
engine_number: '',
sct_permission: '',
color: '',
container_key: ''
});
let loading = $state(false);
let error = $state<string | null>(null);
// Resetear formulario cuando cambia el item
$effect(() => {
if (item) {
formData = { ...item };
} else {
formData = {
vehicle_key: '',
ace_vehicle_key: '',
transporter_key: '',
transport_identifier: '',
transport_type: '',
entity_code: '',
transponder_number: '',
dot_number: '',
plate_number: '',
city: '',
state: '',
country: '',
seal: '',
insurance_company_name: '',
insurance_number: '',
insurance_amount: undefined,
insurance_date: undefined,
box_number: '',
brand: '',
year: '',
series: '',
description: '',
engine_number: '',
sct_permission: '',
color: '',
container_key: ''
};
}
});
async function handleSubmit() {
error = null;
loading = true;
try {
const companyId = companyStore.activeCompany?.id;
if (!companyId) {
throw new Error('No hay una compañía seleccionada');
}
// Validación básica
if (!formData.vehicle_key.trim()) {
throw new Error('La clave del vehículo es requerida');
}
let response;
if (isEdit && item) {
response = await vehiclesApi.update(item.vehicle_key, formData, companyId);
} else {
response = await vehiclesApi.create(formData, companyId);
}
if (response.error) {
throw new Error(response.error);
}
// Cerrar diálogo y notificar éxito
open = false;
if (onSuccess) {
onSuccess();
}
} catch (e) {
error = e instanceof Error ? e.message : 'Error al guardar el vehículo';
} finally {
loading = false;
}
}
function handleCancel() {
open = false;
error = null;
}
</script>
<Dialog.Root bind:open>
<Dialog.Content class="max-h-[90vh] max-w-4xl overflow-y-auto">
<Dialog.Header>
<Dialog.Title>{title}</Dialog.Title>
<Dialog.Description>
{isEdit
? 'Modifica los datos del vehículo'
: 'Completa los datos para crear un nuevo vehículo de transporte'}
</Dialog.Description>
</Dialog.Header>
<form
onsubmit={(e) => {
e.preventDefault();
handleSubmit();
}}
class="space-y-6"
>
{#if error}
<div class="rounded-md bg-destructive/15 p-3 text-sm text-destructive">
{error}
</div>
{/if}
<div class="grid grid-cols-1 gap-6 md:grid-cols-2">
<!-- Información del Vehículo -->
<section class="space-y-4">
<h3 class="border-b pb-2 text-sm font-semibold">Identificación del Vehículo</h3>
<div class="grid gap-2">
<Label for="vehicle_key"
>Clave del Vehículo <span class="text-destructive">*</span></Label
>
<Input
id="vehicle_key"
bind:value={formData.vehicle_key}
disabled={isEdit}
required
maxlength={14}
/>
</div>
<div class="grid grid-cols-2 gap-4">
<div class="grid gap-2">
<Label for="brand">Marca</Label>
<Input id="brand" bind:value={formData.brand} maxlength={20} />
</div>
<div class="grid gap-2">
<Label for="year">Año</Label>
<Input id="year" bind:value={formData.year} maxlength={4} placeholder="YYYY" />
</div>
</div>
<div class="grid gap-2">
<Label for="plate_number">Placas</Label>
<Input id="plate_number" bind:value={formData.plate_number} maxlength={17} />
</div>
<div class="grid gap-2">
<Label for="series">Serie / VIN</Label>
<Input id="series" bind:value={formData.series} maxlength={30} />
</div>
</section>
<!-- Datos de Transporte -->
<section class="space-y-4">
<h3 class="border-b pb-2 text-sm font-semibold">Datos de Transporte</h3>
<div class="grid gap-2">
<Label for="transporter_key">Clave Transportista</Label>
<Input id="transporter_key" bind:value={formData.transporter_key} maxlength={23} />
</div>
<div class="grid gap-2">
<Label for="transport_identifier">Identificador Transporte</Label>
<Input
id="transport_identifier"
bind:value={formData.transport_identifier}
maxlength={30}
/>
</div>
<div class="grid gap-2">
<Label for="transport_type">Tipo de Transporte</Label>
<Input
id="transport_type"
bind:value={formData.transport_type}
maxlength={2}
placeholder="2 car."
/>
</div>
<div class="grid gap-2">
<Label for="sct_permission">Permiso SCT</Label>
<Input id="sct_permission" bind:value={formData.sct_permission} maxlength={40} />
</div>
</section>
<!-- Seguro y Otros -->
<section class="space-y-4">
<h3 class="border-b pb-2 text-sm font-semibold">Seguro y Otros</h3>
<div class="grid gap-2">
<Label for="insurance_company_name">Aseguradora</Label>
<Input
id="insurance_company_name"
bind:value={formData.insurance_company_name}
maxlength={30}
/>
</div>
<div class="grid grid-cols-2 gap-4">
<div class="grid gap-2">
<Label for="insurance_number">Póliza</Label>
<Input id="insurance_number" bind:value={formData.insurance_number} maxlength={20} />
</div>
<div class="grid gap-2">
<Label for="insurance_amount">Monto Seguro</Label>
<Input id="insurance_amount" type="number" bind:value={formData.insurance_amount} />
</div>
</div>
<div class="grid gap-2">
<Label for="dot_number">Número DOT</Label>
<Input id="dot_number" bind:value={formData.dot_number} maxlength={8} />
</div>
</section>
<!-- Ubicación y Detalles -->
<section class="space-y-4">
<h3 class="border-b pb-2 text-sm font-semibold">Ubicación y Detalles</h3>
<div class="grid grid-cols-2 gap-4">
<div class="grid gap-2">
<Label for="state">Estado</Label>
<Input id="state" bind:value={formData.state} maxlength={30} />
</div>
<div class="grid gap-2">
<Label for="country">País</Label>
<Input
id="country"
bind:value={formData.country}
maxlength={3}
placeholder="MEX / USA"
/>
</div>
</div>
<div class="grid gap-2">
<Label for="description">Descripción</Label>
<Input id="description" bind:value={formData.description} maxlength={100} />
</div>
<div class="grid grid-cols-2 gap-4">
<div class="grid gap-2">
<Label for="color">Color</Label>
<Input id="color" bind:value={formData.color} maxlength={20} />
</div>
<div class="grid gap-2">
<Label for="container_key">Contenedor</Label>
<Input
id="container_key"
bind:value={formData.container_key}
maxlength={3}
placeholder="3 car."
/>
</div>
</div>
</section>
</div>
<Dialog.Footer>
<Button type="button" variant="outline" onclick={handleCancel} disabled={loading}>
Cancelar
</Button>
<Button type="submit" disabled={loading}>
{loading ? 'Guardando...' : isEdit ? 'Actualizar' : 'Crear'}
</Button>
</Dialog.Footer>
</form>
</Dialog.Content>
</Dialog.Root>

View File

@@ -0,0 +1,111 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import { vehiclesApi, type Vehicle } from '$lib/api/dashboard/a76/vehicles';
import { companyStore } from '$lib/stores/company.svelte';
import { EllipsisVertical, Pencil, LoaderCircle, Trash2 } from 'lucide-svelte';
import CreateEditDialog from './create-edit-dialog.svelte';
let {
item,
onSuccess
}: {
item: Vehicle;
onSuccess?: () => void;
} = $props();
let loading = $state(false);
let error = $state<string | null>(null);
let dialogOpen = $state(false);
let selectedItem = $state<Vehicle | null>(null);
async function handleDelete() {
if (
!confirm(
`¿Estás seguro de eliminar el vehículo "${item.vehicle_key}"?\n\nNota: No se puede eliminar si tiene registros relacionados.`
)
) {
return;
}
if (!companyStore.activeCompany) {
alert('❌ Error: No hay una compañía seleccionada');
return;
}
loading = true;
error = null;
try {
const response = await vehiclesApi.delete(item.vehicle_key, companyStore.activeCompany.id);
if (response.error) {
if (response.status === 401) {
error = 'Sesión expirada. Recargando página...';
setTimeout(() => {
window.location.reload();
}, 1500);
} else {
error = response.error;
alert(`❌ Error al eliminar:\n\n${response.error}`);
}
return;
}
// Éxito
alert(`✅ Vehículo "${item.vehicle_key}" eliminado correctamente`);
if (onSuccess) {
onSuccess();
}
} catch (e) {
error = e instanceof Error ? e.message : 'Error al eliminar';
alert(`❌ Error: ${error}`);
console.error('Error deleting:', e);
} finally {
loading = false;
}
}
function handleEdit() {
selectedItem = item;
dialogOpen = true;
}
function handleDialogSuccess() {
dialogOpen = false;
selectedItem = null;
if (onSuccess) {
onSuccess();
}
}
</script>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
{#snippet child({ props })}
<Button {...props} variant="ghost" size="icon" class="relative size-8 p-0">
<span class="sr-only">Abrir menú</span>
<EllipsisVertical size={16} />
</Button>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end" class="w-[160px]">
<DropdownMenu.Label>Acciones</DropdownMenu.Label>
<DropdownMenu.Separator />
<DropdownMenu.Item onclick={handleEdit}>
<Pencil size={16} class="mr-2" />
Editar
</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Item onclick={handleDelete} class="text-destructive" disabled={loading}>
{#if loading}
<LoaderCircle class="mr-2 h-4 w-4 animate-spin" />
{:else}
<Trash2 size={16} class="mr-2" />
{/if}
Eliminar
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
<CreateEditDialog bind:open={dialogOpen} item={selectedItem} onSuccess={handleDialogSuccess} />

View File

@@ -0,0 +1,99 @@
<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;
},
get columns() {
return columns;
},
getCoreRowModel: getCoreRowModel(),
manualPagination: true,
get pageCount() {
return 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 bg-card">
<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

@@ -15,6 +15,7 @@ import {
Settings2,
Shield,
Ship,
Truck,
Users,
} from 'lucide-svelte';
import * as m from "$lib/paraglide/messages.js";
@@ -192,6 +193,7 @@ export function getSidebarData(): SidebarData {
title: m["sidebar.general_catalogs.identifiers"](),
url: "/dashboard/general_catalogs/identifiers",
},
// -------------------------------------
{
title: m["sidebar.general_catalogs.incoterms"](),
url: "/dashboard/reference_data/incoterms",
@@ -333,6 +335,25 @@ export function getSidebarData(): SidebarData {
},
],
},
{
title: "Transportes",
url: "#",
icon: Truck,
items: [
{
title: "Transportistas",
url: "/dashboard/general_catalogs/transporters",
},
{
title: "Trailers",
url: "/dashboard/general_catalogs/trailers",
},
{
title: "Vehículos",
url: "/dashboard/general_catalogs/vehicles",
},
],
},
{
title: m["sidebar.goods.title"](),
url: "#",
@@ -522,4 +543,4 @@ export function getSidebarData(): SidebarData {
}
// Exportar también como constante para compatibilidad (deprecado)
export const sidebarData: SidebarData = getSidebarData();
export const sidebarData: SidebarData = getSidebarData();