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

@@ -13,4 +13,4 @@ declare global {
}
}
export {};
export { };

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();

View File

@@ -22,10 +22,29 @@
FileText,
Settings,
User,
Trash2
Trash2,
Search,
Globe,
MapPin as MapPinIcon,
Factory,
Calendar,
Hash,
ShieldCheck,
Award,
Fingerprint,
Briefcase
} from 'lucide-svelte';
import { toast } from 'svelte-sonner';
// Componentes Compartidos (Modales)
import CountrySelectorDialog from '$lib/components/dashboard/goods/modales/country-selector-dialog.svelte';
import StateSelectorDialog from '$lib/components/dashboard/shared/modals/state-selector-dialog.svelte';
import SectorSelectorDialog from '$lib/components/dashboard/shared/modals/sector-selector-dialog.svelte';
import { type Country } from '$lib/api/dashboard/reference_data/countries';
import { type State } from '$lib/api/dashboard/reference_data/states';
import { type Sector } from '$lib/api/dashboard/reference_data/sectors';
// API & Stores
import { clientsProvidersApi } from '$lib/api/dashboard/a76/clients-providers';
import { companyStore } from '$lib/stores/company.svelte';
@@ -73,17 +92,30 @@
program: '',
program_number: '',
authorization_date_str: '', // String para input date
prosec: 0,
prosec: '',
manufacturer_id: '',
tax_id: '',
ctpat_svi: '',
is_certified_company: '0'
is_certified_company: false
});
let formData = $state(getEmptyForm());
let loading = $state(false);
let error = $state<string | null>(null);
// --- ESTADO PARA MODALES ---
let countryModalOpen = $state(false);
let stateModalOpen = $state(false);
let sectorModalOpen = $state(false);
const scaiiPrograms = [
{ value: 'IMMEX', label: 'IMMEX' },
{ value: 'PROSEC', label: 'PROSEC' },
{ value: 'ALTEX', label: 'ALTEX' },
{ value: 'ECEX', label: 'ECEX' },
{ value: 'DRAWBACK', label: 'DRAWBACK' }
];
// --- UTILIDADES ---
const intDateToString = (d?: number | null) =>
d
@@ -138,11 +170,11 @@
program: prog.program || '',
program_number: prog.program_number || '',
authorization_date_str: intDateToString(prog.secon_auth_date),
prosec: prog.prosec || 0,
prosec: prog.prosec ? String(prog.prosec) : '',
manufacturer_id: prog.manufacturer_id || '',
tax_id: prog.tax_id || '',
ctpat_svi: prog.ctpat_svi || '',
is_certified_company: prog.is_certified_company || '0'
is_certified_company: prog.is_certified_company === '1'
};
}
} catch (e: any) {
@@ -202,11 +234,11 @@
program: clean(formData.program),
program_number: clean(formData.program_number),
secon_auth_date: stringDateToInt(formData.authorization_date_str),
prosec: Number(formData.prosec) || null,
prosec: clean(formData.prosec),
manufacturer_id: clean(formData.manufacturer_id),
tax_id: clean(formData.tax_id),
ctpat_svi: clean(formData.ctpat_svi),
is_certified_company: clean(formData.is_certified_company)
is_certified_company: formData.is_certified_company ? '1' : '0'
}
};
@@ -470,17 +502,44 @@
</div>
<div class="grid gap-2">
<Label for="state">Estado</Label>
<Input id="state" bind:value={formData.state} maxlength={30} disabled={loading} />
<div class="flex gap-2">
<Input
id="state"
bind:value={formData.state}
maxlength={30}
disabled={loading}
/>
<Button
variant="outline"
size="icon"
onclick={() => (stateModalOpen = true)}
disabled={loading}
title="Buscar Estado"
>
<Search size={16} />
</Button>
</div>
</div>
<div class="grid gap-2">
<Label for="country">País (ISO)</Label>
<Input
id="country"
bind:value={formData.country}
placeholder="MEX"
maxlength={3}
disabled={loading}
/>
<div class="flex gap-2">
<Input
id="country"
bind:value={formData.country}
placeholder="MEX"
maxlength={3}
disabled={loading}
/>
<Button
variant="outline"
size="icon"
onclick={() => (countryModalOpen = true)}
disabled={loading}
title="Buscar País"
>
<Globe size={16} />
</Button>
</div>
</div>
</div>
@@ -509,77 +568,160 @@
>Información sobre IMMEX, PROSEC y otras certificaciones.</Card.Description
>
</Card.Header>
<Card.Content class="space-y-4">
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="grid gap-2">
<Label for="program">Programa</Label>
<Input
id="program"
bind:value={formData.program}
placeholder="IMMEX"
maxlength={7}
disabled={loading}
/>
<Card.Content class="space-y-8">
<!-- Section: Promotion Programs -->
<div class="space-y-4">
<div class="flex items-center gap-2 text-sm font-semibold text-white">
<Briefcase size={18} />
<span>Programas de Fomento</span>
</div>
<div class="grid gap-2">
<Label for="program_num">Número de Programa</Label>
<Input
id="program_num"
bind:value={formData.program_number}
maxlength={40}
disabled={loading}
/>
<Separator />
<div class="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-4">
<div class="grid gap-2 lg:col-span-1">
<Label for="program" class="flex items-center gap-2">
<Building2 size={14} class="text-muted-foreground" />
Programa
</Label>
<Select.Root
type="single"
value={formData.program}
onValueChange={(v) => (formData.program = v)}
disabled={loading}
>
<Select.Trigger id="program" class="w-full">
{formData.program || 'Selecciona un programa'}
</Select.Trigger>
<Select.Content>
{#each scaiiPrograms as prog}
<Select.Item value={prog.value} label={prog.label}>
{prog.label}
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</div>
<div class="grid gap-2">
<Label for="program_num" class="flex items-center gap-2">
<Hash size={14} class="text-muted-foreground" />
Número de Programa
</Label>
<Input
id="program_num"
bind:value={formData.program_number}
maxlength={40}
disabled={loading}
placeholder="P. ej. 1234-2024"
/>
</div>
<div class="grid gap-2">
<Label for="auth_date" class="flex items-center gap-2">
<Calendar size={14} class="text-muted-foreground" />
Fecha Autorización
</Label>
<Input
id="auth_date"
type="date"
bind:value={formData.authorization_date_str}
disabled={loading}
/>
</div>
<div class="grid gap-2">
<Label for="prosec" class="flex items-center gap-2">
<Factory size={14} class="text-muted-foreground" />
PROSEC (Sector)
</Label>
<div class="flex gap-2">
<Input
id="prosec"
bind:value={formData.prosec}
disabled={loading}
placeholder="Ej: XII, IV"
/>
<Button
variant="outline"
size="icon"
onclick={() => (sectorModalOpen = true)}
disabled={loading}
title="Buscar Sector"
>
<Search size={16} />
</Button>
</div>
</div>
</div>
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-3">
<div class="grid gap-2">
<Label for="auth_date">Fecha Autorización</Label>
<Input
id="auth_date"
type="date"
bind:value={formData.authorization_date_str}
disabled={loading}
/>
<!-- Section: Industrial Identification -->
<div class="space-y-4 pt-4">
<div class="flex items-center gap-2 text-sm font-semibold text-white">
<Fingerprint size={18} />
<span>Identificación Industrial</span>
</div>
<div class="grid gap-2">
<Label for="tax_id">Tax ID (Extranjero)</Label>
<Input
id="tax_id"
bind:value={formData.tax_id}
maxlength={30}
disabled={loading}
/>
</div>
<div class="grid gap-2">
<Label for="man_id">Manufacturer ID</Label>
<Input
id="man_id"
bind:value={formData.manufacturer_id}
maxlength={25}
disabled={loading}
/>
<Separator />
<div class="grid grid-cols-1 gap-6 md:grid-cols-2">
<div class="grid gap-2">
<Label for="tax_id" class="flex items-center gap-2">
<Globe size={14} class="text-muted-foreground" />
Tax ID (Extranjero)
</Label>
<Input
id="tax_id"
bind:value={formData.tax_id}
maxlength={30}
disabled={loading}
placeholder="Identificador fiscal extranjero"
/>
</div>
<div class="grid gap-2">
<Label for="man_id" class="flex items-center gap-2">
<FileText size={14} class="text-muted-foreground" />
Manufacturer ID (MID)
</Label>
<Input
id="man_id"
bind:value={formData.manufacturer_id}
maxlength={25}
disabled={loading}
placeholder="P. ej. MXABCD12345"
/>
</div>
</div>
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="grid gap-2">
<Label for="ctpat">C-TPAT / SVI</Label>
<Input
id="ctpat"
bind:value={formData.ctpat_svi}
maxlength={100}
disabled={loading}
/>
<!-- Section: Certifications & Security -->
<div class="space-y-4 pt-4">
<div class="flex items-center gap-2 text-sm font-semibold text-white">
<ShieldCheck size={18} />
<span>Certificaciones y Seguridad</span>
</div>
<div class="grid gap-2">
<Label for="prosec">PROSEC (Sector)</Label>
<Input
id="prosec"
type="number"
bind:value={formData.prosec}
disabled={loading}
/>
<Separator />
<div class="grid grid-cols-1 gap-6 md:grid-cols-2">
<div class="grid gap-2">
<Label for="ctpat" class="flex items-center gap-2">
<Award size={14} class="text-muted-foreground" />
C-TPAT / SVI
</Label>
<Input
id="ctpat"
bind:value={formData.ctpat_svi}
maxlength={100}
disabled={loading}
placeholder="P. ej. SVI-12345"
/>
</div>
<div class="flex items-center gap-3 rounded-lg border bg-card/50 p-4">
<Switch
id="is_certified"
bind:checked={formData.is_certified_company}
disabled={loading}
/>
<div class="grid gap-0.5">
<Label for="is_certified">Empresa Certificada</Label>
<p class="text-xs text-muted-foreground">
Indica si cuenta con certificación de empresa
</p>
</div>
</div>
</div>
</div>
</Card.Content>
@@ -670,3 +812,24 @@
</div>
</div>
</div>
<!-- MODALES DE SELECCIÓN -->
<CountrySelectorDialog
bind:open={countryModalOpen}
onSelect={(country) => (formData.country = country.m3_key)}
/>
<StateSelectorDialog
bind:open={stateModalOpen}
onSelect={(state) => {
formData.state = state.description;
if (state.m3_key && !formData.country) {
formData.country = state.m3_key;
}
}}
/>
<SectorSelectorDialog
bind:open={sectorModalOpen}
onSelect={(sector) => (formData.prosec = sector.key)}
/>

View File

@@ -187,8 +187,7 @@
const brokerColumns = createBrokerColumns(handleActionSuccess);
</script>
<div class="flex flex-col h-[calc(100vh-4rem)] p-4 gap-4 pb-15">
<!-- Title -->
<div class="flex h-[calc(100vh-4rem)] flex-col gap-4 p-4 pb-15">
<div class="flex items-center justify-between">
<div class="flex flex-col gap-1">
<h1 class="text-2xl font-bold">GESTIÓN ADUANAL</h1>
@@ -196,17 +195,17 @@
</div>
</div>
<Tabs.Root bind:value={activeTab} class="flex-1 flex flex-col overflow-hidden">
<Tabs.List class="w-full justify-start border-b rounded-none bg-transparent p-0 mb-4">
<Tabs.Root bind:value={activeTab} class="flex flex-1 flex-col overflow-hidden">
<Tabs.List class="mb-4 w-full justify-start rounded-none border-b bg-transparent p-0">
<Tabs.Trigger
value="brokers"
class="data-[state=active]:border-primary border-b-2 border-transparent rounded-none"
class="rounded-none border-b-2 border-transparent data-[state=active]:border-primary"
>
Agentes Aduanales
</Tabs.Trigger>
<Tabs.Trigger
value="customs"
class="data-[state=active]:border-primary border-b-2 border-transparent rounded-none"
class="rounded-none border-b-2 border-transparent data-[state=active]:border-primary"
>
Secciones Aduanales
</Tabs.Trigger>
@@ -214,13 +213,11 @@
<Tabs.Content
value="brokers"
class="flex-1 flex gap-4 overflow-hidden mt-0 data-[state=inactive]:hidden"
class="mt-0 flex flex-1 gap-4 overflow-hidden data-[state=inactive]:hidden"
>
<!-- Left Panel: Table -->
<div class="flex-1 flex flex-col gap-4 overflow-hidden">
<!-- Filters -->
<div class="border rounded-lg bg-card">
<div class="p-4 space-y-4">
<div class="flex flex-1 flex-col gap-4 overflow-hidden">
<div class="rounded-lg border bg-card">
<div class="space-y-4 p-4">
<div class="flex items-center justify-between">
<h2 class="text-sm font-semibold">Filtros</h2>
<span class="text-xs text-muted-foreground">Busque por nombre o patente</span>
@@ -244,23 +241,20 @@
oninput={handleSearch}
/>
</div>
<div class="flex items-end">
<!-- Placeholder for layout balance -->
</div>
<div class="flex items-end"></div>
</div>
</div>
</div>
<!-- Table -->
<div class="flex-1 flex flex-col border rounded-lg overflow-hidden">
<div class="flex items-center justify-between p-3 border-b bg-muted/30">
<div class="flex flex-1 flex-col overflow-hidden rounded-lg border">
<div class="flex items-center justify-between border-b bg-muted/30 p-3">
<h2 class="text-sm font-semibold">Listado</h2>
<div class="flex items-center gap-2">
<span class="text-xs text-muted-foreground">
{filteredItems.length} registros
</span>
<Button variant="outline" size="sm" onclick={loadItems}>
<RefreshCw class="h-4 w-4 mr-2" />
<RefreshCw class="mr-2 h-4 w-4" />
Actualizar
</Button>
</div>
@@ -275,9 +269,8 @@
idField="broker_key"
/>
</div>
<!-- Simple Pagination Controls -->
{#if totalItems > pageSize}
<div class="p-2 border-t flex justify-end gap-2">
<div class="flex justify-end gap-2 border-t p-2">
<Button
variant="outline"
size="sm"
@@ -286,7 +279,7 @@
>
Anterior
</Button>
<span class="flex items-center text-xs text-muted-foreground px-2">
<span class="flex items-center px-2 text-xs text-muted-foreground">
Página {currentPage} de {Math.ceil(totalItems / pageSize)}
</span>
<Button
@@ -302,33 +295,32 @@
</div>
</div>
<!-- Right Panel: Details -->
<div
class="w-96 flex-none flex flex-col border rounded-xl bg-muted/30 shadow-sm overflow-hidden"
class="flex w-96 flex-none flex-col overflow-hidden rounded-xl border bg-muted/30 shadow-sm"
>
<div class="p-4 border-b bg-card">
<p class="text-[10px] uppercase tracking-widest opacity-80 text-muted-foreground">
<div class="border-b bg-card p-4">
<p class="text-[10px] tracking-widest text-muted-foreground uppercase opacity-80">
Detalles del Agente
</p>
<h2
class="text-xl font-black font-mono tracking-tighter truncate"
class="truncate font-mono text-xl font-black tracking-tighter"
title={selectedItem?.name || ''}
>
{selectedItem?.name || '---'}
</h2>
<div class="flex items-center gap-2 mt-1">
<span class="text-xs font-mono text-muted-foreground"
<div class="mt-1 flex items-center gap-2">
<span class="font-mono text-xs text-muted-foreground"
>Patente: {selectedItem?.broker_key || ''}</span
>
</div>
</div>
<div class="flex-1 overflow-auto p-5 space-y-6 bg-card">
<div class="flex-1 space-y-6 overflow-auto bg-card p-5">
{#if selectedItem}
<div class="grid grid-cols-1 gap-4">
<div class="space-y-1">
<Label
class="text-[10px] uppercase text-muted-foreground font-bold flex items-center gap-1"
class="flex items-center gap-1 text-[10px] font-bold text-muted-foreground uppercase"
>
<FileText size={10} /> Licencia / Autorización
</Label>
@@ -338,21 +330,21 @@
{#if selectedItem.tax_id}
<div class="space-y-1">
<Label
class="text-[10px] uppercase text-muted-foreground font-bold flex items-center gap-1"
class="flex items-center gap-1 text-[10px] font-bold text-muted-foreground uppercase"
>
<Hash size={10} /> RFC / Tax ID
</Label>
<p class="text-sm font-mono">{selectedItem.tax_id}</p>
<p class="font-mono text-sm">{selectedItem.tax_id}</p>
</div>
{/if}
<div class="pt-4 border-t space-y-3">
<div class="space-y-3 border-t pt-4">
<Label
class="text-[10px] uppercase text-muted-foreground font-bold flex items-center gap-1"
class="flex items-center gap-1 text-[10px] font-bold text-muted-foreground uppercase"
>
<MapPin size={10} /> Dirección
</Label>
<div class="text-sm space-y-1">
<div class="space-y-1 text-sm">
<p>{selectedItem.address || ''}</p>
<p>
{[selectedItem.city, selectedItem.state].filter(Boolean).join(', ')}
@@ -363,9 +355,9 @@
</div>
</div>
<div class="pt-4 border-t space-y-3">
<div class="space-y-3 border-t pt-4">
<Label
class="text-[10px] uppercase text-muted-foreground font-bold flex items-center gap-1"
class="flex items-center gap-1 text-[10px] font-bold text-muted-foreground uppercase"
>
<Phone size={10} /> Contacto
</Label>
@@ -391,9 +383,9 @@
</div>
{:else}
<div
class="flex flex-col items-center justify-center h-full text-center text-muted-foreground opacity-50"
class="flex h-full flex-col items-center justify-center text-center text-muted-foreground opacity-50"
>
<Building2 class="h-12 w-12 mb-3" />
<Building2 class="mb-3 h-12 w-12" />
<p class="text-sm">Selecciona un agente</p>
</div>
{/if}
@@ -401,9 +393,8 @@
</div>
</Tabs.Content>
<Tabs.Content value="customs" class="flex-1 overflow-auto mt-0 data-[state=inactive]:hidden">
<!-- Reusing DataTable from Customs Sections -->
<Card.Root class="h-full flex flex-col border-none shadow-none">
<Tabs.Content value="customs" class="mt-0 flex-1 overflow-auto data-[state=inactive]:hidden">
<Card.Root class="flex h-full flex-col border-none shadow-none">
<Card.Content class="flex-1 p-0">
<SectionsDataTable
data={sections}
@@ -418,13 +409,13 @@
</Tabs.Root>
</div>
<div
class="fixed bottom-0 left-0 right-0 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80 border-t shadow-lg z-[5] group-has-data-[state=collapsed]/sidebar-wrapper:ml-[calc(var(--sidebar-width-icon))] ml-[calc(var(--sidebar-width))]"
class="fixed right-0 bottom-0 left-0 z-[5] ml-[calc(var(--sidebar-width))] border-t bg-background/95 shadow-lg backdrop-blur group-has-data-[state=collapsed]/sidebar-wrapper:ml-[calc(var(--sidebar-width-icon))] supports-[backdrop-filter]:bg-background/80"
>
<div class="px-4 py-4 max-w-[1400px] mx-auto">
<div class="mx-auto max-w-[1400px] px-4 py-4">
<div class="flex justify-end gap-2">
{#if activeTab === 'brokers'}
<Button size="sm" href="/dashboard/customs_brokers/edit">
<Plus class="h-4 w-4 mr-1" />
<Button size="sm" href="/dashboard/customs_brokers/edit/new">
<Plus class="mr-1 h-4 w-4" />
Nuevo
</Button>
<Button variant="outline" size="sm" onclick={handleEdit} disabled={!selectedItem}>
@@ -441,7 +432,7 @@
</Button>
{:else}
<Button size="sm" onclick={() => toast.info('Pendiente')}>
<Plus class="h-4 w-4 mr-1" />
<Plus class="mr-1 h-4 w-4" />
Nueva Sección
</Button>
{/if}

View File

@@ -0,0 +1,117 @@
<script lang="ts">
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import { Plus } from 'lucide-svelte';
// Importar componentes de la librería
import DataTable from '$lib/components/dashboard/transportation/trailers/data-table.svelte';
import CreateEditDialog from '$lib/components/dashboard/transportation/trailers/create-edit-dialog.svelte';
import { createColumns } from '$lib/components/dashboard/transportation/trailers/columns';
import { trailersApi, type Trailer } from '$lib/api/dashboard/a76/trailers';
import { companyStore } from '$lib/stores/company.svelte';
import { browser } from '$app/environment';
// --- ESTADO ---
let data = $state<Trailer[]>([]);
let totalItems = $state(0);
let pageCount = $state(0);
let loading = $state(false);
let createDialogOpen = $state(false);
let currentPage = $derived(Number($page.url.searchParams.get('page')) || 1);
let pageSize = 10;
// Filtros
let searchNumber = $state($page.url.searchParams.get('trailer_number') || '');
let searchPlate = $state($page.url.searchParams.get('plate_number') || '');
let searchTimeout: NodeJS.Timeout;
// --- LOGICA ---
async function loadData() {
if (!companyStore.activeCompany) return;
loading = true;
try {
const response = await trailersApi.list(companyStore.activeCompany.id, {
page: currentPage,
page_size: pageSize,
trailer_number: searchNumber,
plate_number: searchPlate
});
if (response.data) {
data = response.data.items;
totalItems = response.data.total;
pageCount = Math.ceil(response.data.total / response.data.page_size);
}
} catch (error) {
console.error('Error loading trailers:', error);
} finally {
loading = false;
}
}
function handleSearch() {
if (!browser) return;
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
const url = new URL($page.url);
url.searchParams.set('page', '1');
if (searchNumber) url.searchParams.set('trailer_number', searchNumber);
else url.searchParams.delete('trailer_number');
if (searchPlate) url.searchParams.set('plate_number', searchPlate);
else url.searchParams.delete('plate_number');
goto(url, { keepFocus: true, noScroll: true });
}, 500);
}
// Recargar datos cuando cambia el contexto de compañía o la página/filtros
$effect(() => {
const _ = { p: $page.url.href, c: companyStore.activeCompany?.id };
loadData();
});
const columns = createColumns(loadData);
</script>
<div class="flex h-full flex-col space-y-6 p-8">
<div class="flex items-center justify-between">
<div>
<h2 class="text-2xl font-bold tracking-tight">Trailers</h2>
<p class="text-muted-foreground">Gestión del catálogo de trailers de la compañía</p>
</div>
<Button onclick={() => (createDialogOpen = true)}>
<Plus class="mr-2 h-4 w-4" /> Nuevo Trailer
</Button>
</div>
<div class="flex items-center space-x-2">
<Input
placeholder="Buscar por número..."
class="h-8 w-[250px] bg-card"
bind:value={searchNumber}
oninput={handleSearch}
/>
<Input
placeholder="Buscar por placas..."
class="h-8 w-[250px] bg-card"
bind:value={searchPlate}
oninput={handleSearch}
/>
</div>
{#if loading && data.length === 0}
<div class="flex h-64 items-center justify-center text-muted-foreground">
Cargando trailers...
</div>
{:else}
<DataTable {data} {columns} {pageCount} {totalItems} />
{/if}
<CreateEditDialog bind:open={createDialogOpen} onSuccess={loadData} />
</div>

View File

@@ -0,0 +1,117 @@
<script lang="ts">
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import { Plus } from 'lucide-svelte';
// Importar componentes de la librería
import DataTable from '$lib/components/dashboard/transportation/transporters/data-table.svelte';
import CreateEditDialog from '$lib/components/dashboard/transportation/transporters/create-edit-dialog.svelte';
import { createColumns } from '$lib/components/dashboard/transportation/transporters/transporter-columns';
import { transportersApi, type Transporter } from '$lib/api/dashboard/a76/transporters';
import { companyStore } from '$lib/stores/company.svelte';
import { browser } from '$app/environment';
// --- ESTADO ---
let data = $state<Transporter[]>([]);
let totalItems = $state(0);
let pageCount = $state(0);
let loading = $state(false);
let createDialogOpen = $state(false);
let currentPage = $derived(Number($page.url.searchParams.get('page')) || 1);
let pageSize = 10;
// Filtros
let searchKey = $state($page.url.searchParams.get('transporter_key') || '');
let searchName = $state($page.url.searchParams.get('name') || '');
let searchTimeout: NodeJS.Timeout;
// --- LOGICA ---
async function loadData() {
if (!companyStore.activeCompany) return;
loading = true;
try {
const response = await transportersApi.list(companyStore.activeCompany.id, {
page: currentPage,
page_size: pageSize,
transporter_key: searchKey,
name: searchName
});
if (response.data) {
data = response.data.items;
totalItems = response.data.total;
pageCount = Math.ceil(response.data.total / response.data.page_size);
}
} catch (error) {
console.error('Error loading transporters:', error);
} finally {
loading = false;
}
}
function handleSearch() {
if (!browser) return;
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
const url = new URL($page.url);
url.searchParams.set('page', '1');
if (searchKey) url.searchParams.set('transporter_key', searchKey);
else url.searchParams.delete('transporter_key');
if (searchName) url.searchParams.set('name', searchName);
else url.searchParams.delete('name');
goto(url, { keepFocus: true, noScroll: true });
}, 500);
}
// Recargar datos cuando cambia el contexto de compañía o la página/filtros
$effect(() => {
const _ = { p: $page.url.href, c: companyStore.activeCompany?.id };
loadData();
});
const columns = createColumns(loadData);
</script>
<div class="flex h-full flex-col space-y-6 p-8">
<div class="flex items-center justify-between">
<div>
<h2 class="text-2xl font-bold tracking-tight">Transportistas</h2>
<p class="text-muted-foreground">Gestión del catálogo de líneas transportistas</p>
</div>
<Button onclick={() => (createDialogOpen = true)}>
<Plus class="mr-2 h-4 w-4" /> Nuevo Transportista
</Button>
</div>
<div class="flex items-center space-x-2">
<Input
placeholder="Buscar por clave..."
class="h-8 w-[250px] bg-card"
bind:value={searchKey}
oninput={handleSearch}
/>
<Input
placeholder="Buscar por nombre..."
class="h-8 w-[250px] bg-card"
bind:value={searchName}
oninput={handleSearch}
/>
</div>
{#if loading && data.length === 0}
<div class="flex h-64 items-center justify-center text-muted-foreground">
Cargando transportistas...
</div>
{:else}
<DataTable {data} {columns} {pageCount} {totalItems} />
{/if}
<CreateEditDialog bind:open={createDialogOpen} onSuccess={loadData} />
</div>

View File

@@ -0,0 +1,119 @@
<script lang="ts">
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import { Plus } from 'lucide-svelte';
// Importar componentes de la librería
import DataTable from '$lib/components/dashboard/transportation/vehicles/data-table.svelte';
import CreateEditDialog from '$lib/components/dashboard/transportation/vehicles/create-edit-dialog.svelte';
import { createColumns } from '$lib/components/dashboard/transportation/vehicles/columns';
import { vehiclesApi, type Vehicle } from '$lib/api/dashboard/a76/vehicles';
import { companyStore } from '$lib/stores/company.svelte';
import { browser } from '$app/environment';
// --- ESTADO ---
let data = $state<Vehicle[]>([]);
let totalItems = $state(0);
let pageCount = $state(0);
let loading = $state(false);
let createDialogOpen = $state(false);
let currentPage = $derived(Number($page.url.searchParams.get('page')) || 1);
let pageSize = 10;
// Filtros
let searchKey = $state($page.url.searchParams.get('vehicle_key') || '');
let searchPlate = $state($page.url.searchParams.get('plate_number') || '');
let searchTimeout: NodeJS.Timeout;
// --- LOGICA ---
async function loadData() {
if (!companyStore.activeCompany) return;
loading = true;
try {
const response = await vehiclesApi.list(companyStore.activeCompany.id, {
page: currentPage,
page_size: pageSize,
vehicle_key: searchKey,
plate_number: searchPlate
});
if (response.data) {
data = response.data.items;
totalItems = response.data.total;
pageCount = Math.ceil(response.data.total / response.data.page_size);
}
} catch (error) {
console.error('Error loading vehicles:', error);
} finally {
loading = false;
}
}
function handleSearch() {
if (!browser) return;
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
const url = new URL($page.url);
url.searchParams.set('page', '1');
if (searchKey) url.searchParams.set('vehicle_key', searchKey);
else url.searchParams.delete('vehicle_key');
if (searchPlate) url.searchParams.set('plate_number', searchPlate);
else url.searchParams.delete('plate_number');
goto(url, { keepFocus: true, noScroll: true });
}, 500);
}
// Recargar datos cuando cambia el contexto de compañía o la página/filtros
$effect(() => {
const _ = { p: $page.url.href, c: companyStore.activeCompany?.id };
loadData();
});
const columns = createColumns(loadData);
</script>
<div class="flex h-full flex-col space-y-6 p-8">
<div class="flex items-center justify-between">
<div>
<h2 class="text-2xl font-bold tracking-tight">Vehículos (Transporte)</h2>
<p class="text-muted-foreground">
Gestión del catálogo de camiones y vehículos de transporte
</p>
</div>
<Button onclick={() => (createDialogOpen = true)}>
<Plus class="mr-2 h-4 w-4" /> Nuevo Vehículo
</Button>
</div>
<div class="flex items-center space-x-2">
<Input
placeholder="Buscar por clave..."
class="h-8 w-[250px] bg-card"
bind:value={searchKey}
oninput={handleSearch}
/>
<Input
placeholder="Buscar por placas..."
class="h-8 w-[250px] bg-card"
bind:value={searchPlate}
oninput={handleSearch}
/>
</div>
{#if loading && data.length === 0}
<div class="flex h-64 items-center justify-center text-muted-foreground">
Cargando vehículos...
</div>
{:else}
<DataTable {data} {columns} {pageCount} {totalItems} />
{/if}
<CreateEditDialog bind:open={createDialogOpen} onSuccess={loadData} />
</div>

7
frontend/src/svelte-shims.d.ts vendored Normal file
View File

@@ -0,0 +1,7 @@
// Ambient type declarations for .svelte files
// This must be a script (no top-level import/export) to be globally ambient
declare module "*.svelte" {
import type { Component } from "svelte";
const component: Component<any>;
export default component;
}