CRUD de agentes aduanales funcional, edicion y creacion

This commit is contained in:
2026-01-05 10:12:15 -06:00
parent c95c7f7c55
commit fac675533c
6 changed files with 688 additions and 405 deletions

View File

@@ -1,35 +1,64 @@
from typing import Dict, Any
from api.v1.common.tenant_crud_routes import TenantCRUDRoutes
from core.database import get_core_db
from core.security import get_current_user, validate_access_to_resource
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from core.database import get_core_db
from core.security import get_current_user, validate_access_to_resource
from api.v1.common.tenant_crud_routes import TenantCRUDRoutes
from . import dto, services
# Create main router
router = APIRouter()
# Create CRUD routes for CustomsBroker using TenantCRUDRoutes
customs_broker_crud = TenantCRUDRoutes(
service=services.CustomsBrokerService,
create_schema=dto.CustomsBrokerCreateDTO,
update_schema=dto.CustomsBrokerUpdateDTO,
response_schema=dto.CustomsBrokerResponseDTO,
prefix="/customs-brokers", # No prefix since it's already in the parent router
prefix="/customs-brokers",
tags=[],
resource_name="Customs Broker",
id_name="broker_key",
id_type=str,
enable_list=True, # Enable list endpoint with pagination
enable_list=True,
)
# Include the CRUD routes
router.include_router(customs_broker_crud.router)
# Additional routes for child resources (CustomsBrokerVU and CustomsBrokerPersonnel)
# These remain as manual routes since they have different patterns
@router.patch(
"/customs-brokers/{broker_key}",
response_model=dto.CustomsBrokerResponseDTO,
)
def update_customs_broker(
broker_key: str,
broker_data: dto.CustomsBrokerUpdateDTO,
company_id: int = Query(..., description="Company ID"),
db: Session = Depends(get_core_db),
current_user: Dict[str, Any] = Depends(get_current_user),
):
"""
Actualización parcial (PATCH).
"""
tenant_id = validate_access_to_resource(db, company_id, current_user)
broker = services.CustomsBrokerService.get_by_id(db, broker_key, tenant_id, company_id)
if not broker:
raise HTTPException(status_code=404, detail="Customs Broker not found")
updated_broker = services.CustomsBrokerService.update(
db=db,
broker_key=broker_key,
tenant_id=tenant_id,
broker_data=broker_data,
company_id=company_id
)
if not updated_broker:
raise HTTPException(status_code=400, detail="Error updating Customs Broker")
return updated_broker
@router.put(
"/customs-broker-vu/{broker_key}",
@@ -44,7 +73,6 @@ def update_customs_broker_vu(
):
tenant_id = validate_access_to_resource(db, company_id, current_user)
# Verify the broker exists and belongs to the tenant/company
broker = services.CustomsBrokerService.get_by_id(db, broker_key, tenant_id, company_id)
if not broker:
raise HTTPException(status_code=404, detail="Customs Broker not found")
@@ -69,7 +97,6 @@ def update_customs_broker_personnel(
):
tenant_id = validate_access_to_resource(db, company_id, current_user)
# Verify the broker exists and belongs to the tenant/company
broker = services.CustomsBrokerService.get_by_id(db, broker_key, tenant_id, company_id)
if not broker:
raise HTTPException(status_code=404, detail="Customs Broker not found")
@@ -81,5 +108,4 @@ def update_customs_broker_personnel(
raise HTTPException(
status_code=404, detail="Customs Broker Personnel not found"
)
return updated_personnel
return updated_personnel

View File

@@ -1,141 +1,118 @@
import { api } from '$lib/api';
import type { ApiResponse } from '$lib/api';
export interface CustomsBroker {
type?: string | null;
broker_key: string;
name?: string | null;
address?: string | null;
postal_code?: string | null;
city?: string | null;
state?: string | null;
phone?: string | null;
fax?: string | null;
email?: string | null;
country?: string | null;
tax_id?: string | null;
personal_id?: string | null;
position?: string | null;
license: string;
company?: string | null;
contact?: string | null;
tenant_id: string;
company_id: string;
type?: string | null;
broker_key: string;
name?: string | null;
address?: string | null;
postal_code?: string | null;
city?: string | null;
state?: string | null;
phone?: string | null;
fax?: string | null;
email?: string | null;
country?: string | null;
tax_id?: string | null;
personal_id?: string | null;
position?: string | null;
license: string;
company?: string | null;
contact?: string | null;
tenant_id: string;
company_id: string;
}
export interface CustomsBrokerVU {
certificate_path?: string | null;
key_path?: string | null;
access_key?: string | null;
fiel_format?: string | null;
signature_read_path?: string | null;
archive_path?: string | null;
fiel_access_key?: string | null;
web_service_user?: string | null;
web_service_access_key?: string | null;
vu_email?: string | null;
vu_figure_type?: string | null;
xml_files_path?: string | null;
query_tax_id?: string | null;
doda_certificate_path?: string | null;
doda_key_path?: string | null;
doda_web_service_user?: string | null;
doda_web_service_access_key?: string | null;
doda_fiel_access_key?: string | null;
doda_xml_files_path?: string | null;
certificate_path?: string | null;
key_path?: string | null;
access_key?: string | null;
fiel_format?: string | null;
signature_read_path?: string | null;
archive_path?: string | null;
fiel_access_key?: string | null;
web_service_user?: string | null;
web_service_access_key?: string | null;
vu_email?: string | null;
vu_figure_type?: string | null;
xml_files_path?: string | null;
query_tax_id?: string | null;
doda_certificate_path?: string | null;
doda_key_path?: string | null;
doda_web_service_user?: string | null;
doda_web_service_access_key?: string | null;
doda_fiel_access_key?: string | null;
doda_xml_files_path?: string | null;
}
export interface CustomsBrokerPersonnel {
broker_key: string;
line: number;
name?: string | null;
tax_id?: string | null;
personal_id?: string | null;
position?: string | null;
license?: string | null;
first_name?: string | null;
last_name?: string | null;
middle_name?: string | null;
email?: string | null;
broker_key: string;
line: number;
name?: string | null;
tax_id?: string | null;
personal_id?: string | null;
position?: string | null;
license?: string | null;
first_name?: string | null;
last_name?: string | null;
middle_name?: string | null;
email?: string | null;
}
export interface CreateCustomsBrokerData {
type?: string | null;
broker_key: string;
name?: string | null;
address?: string | null;
postal_code?: string | null;
city?: string | null;
state?: string | null;
phone?: string | null;
fax?: string | null;
email?: string | null;
country?: string | null;
tax_id?: string | null;
personal_id?: string | null;
position?: string | null;
license?: string | null;
company?: string | null;
contact?: string | null;
tenant_id: string;
company_id: string;
type?: string | null;
broker_key: string;
name?: string | null;
address?: string | null;
postal_code?: string | null;
city?: string | null;
state?: string | null;
phone?: string | null;
fax?: string | null;
email?: string | null;
country?: string | null;
tax_id?: string | null;
personal_id?: string | null;
position?: string | null;
license?: string | null;
company?: string | null;
contact?: string | null;
tenant_id?: string;
company_id: string;
}
/**
* API para Agentes Aduanales
*/
export const customsBrokersApi = {
/**
* Lista todos los agentes aduanales
*/
list: (companyId: string) => {
return api.get<CustomsBroker[]>(`/v1/a76/customs-brokers/?company_id=${companyId}`);
},
list: (companyId: string) => {
return api.get<CustomsBroker[]>(`/v1/a76/customs-brokers/?company_id=${companyId}`);
},
/**
* Obtiene un agente aduanal por su clave
*/
get: (brokerKey: string) => {
return api.get<CustomsBroker>(`/v1/a76/customs-brokers/${brokerKey}`);
},
get: (brokerKey: string, companyId: string) => {
return api.get<CustomsBroker>(`/v1/a76/customs-brokers/${brokerKey}?company_id=${companyId}`);
},
create: (data: CreateCustomsBrokerData, companyId: string) => {
return api.post<CustomsBroker>(`/v1/a76/customs-brokers/?company_id=${companyId}`, data);
},
/**
* Crea un nuevo agente aduanal
*/
create: (data: CreateCustomsBrokerData) => {
const companyId = data.company_id;
return api.post<CustomsBroker>(`/v1/a76/customs-brokers/?company_id=${companyId}`, data);
},
update: (brokerKey: string, data: CreateCustomsBrokerData, companyId: string) => {
return api.patch<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}`);
},
delete: (brokerKey: string, companyId: string) => {
return api.delete<void>(`/v1/a76/customs-brokers/${brokerKey}?company_id=${companyId}`);
},
/**
* Actualiza la información de un agente aduanal
*/
update: (brokerKey: string, data: CreateCustomsBrokerData) => {
const companyId = data.company_id;
return api.put<CustomsBroker>(`/v1/a76/customs-brokers/${brokerKey}?company_id=${companyId}`, data);
},
updateVU: (brokerKey: string, data: CustomsBrokerVU, companyId: string) => {
return api.put<CustomsBrokerVU>(`/v1/a76/customs-broker-vu/${brokerKey}?company_id=${companyId}`, data);
},
/**
* Actualiza la información de VU de un agente aduanal
*/
updateVU: (brokerKey: string, data: CustomsBrokerVU) => {
return api.put<CustomsBrokerVU>(`/v1/a76/customs-broker-vu/${brokerKey}`, data);
},
/**
* Actualiza el personal de un agente aduanal
*/
updatePersonnel: (brokerKey: string, line: number, data: CustomsBrokerPersonnel) => {
return api.put<CustomsBrokerPersonnel>(
`/v1/a76/customs-broker-personnel/${brokerKey}/${line}`,
data
);
}
};
updatePersonnel: (brokerKey: string, line: number, data: CustomsBrokerPersonnel, companyId: string) => {
return api.put<CustomsBrokerPersonnel>(
`/v1/a76/customs-broker-personnel/${brokerKey}/${line}?company_id=${companyId}`,
data
);
}
};

View File

@@ -5,7 +5,7 @@
import type { CustomsBroker } from "./columns.js";
import DetailsDialog from "./details-dialog.svelte";
import DeleteDialog from "./delete-dialog.svelte";
import EditDialog from "./edit-dialog.svelte";
import { goto } from "$app/navigation";
let {
broker,
@@ -17,7 +17,6 @@
let showDetailsDialog = $state(false);
let showDeleteDialog = $state(false);
let showEditDialog = $state(false);
function handleCopyKey() {
navigator.clipboard.writeText(broker.broker_key);
@@ -52,7 +51,7 @@
<span class="sr-only">Abrir menú</span>
<EllipsisIcon class="h-4 w-4" />
</Button>
</DropdownMenu.Trigger>
</DropdownMenu.Trigger>
<DropdownMenu.Content>
<DropdownMenu.Group>
<DropdownMenu.Label>Acciones</DropdownMenu.Label>
@@ -70,7 +69,7 @@
<DropdownMenu.Item onclick={handleViewDetails}>
Ver detalles
</DropdownMenu.Item>
<DropdownMenu.Item onclick={handleEdit}>
<DropdownMenu.Item onclick={() => goto(`/dashboard/customs_brokers/edit/${broker.broker_key}`)}>
Editar
</DropdownMenu.Item>
<DropdownMenu.Item onclick={handleDelete} class="text-destructive">
@@ -81,5 +80,4 @@
</DropdownMenu.Root>
<DetailsDialog bind:open={showDetailsDialog} {broker} />
<EditDialog bind:open={showEditDialog} {broker} {onSuccess} />
<DeleteDialog bind:open={showDeleteDialog} {broker} {onSuccess} />

View File

@@ -1,5 +1,4 @@
<script lang="ts" generics="TData, TValue">
import { onMount } from 'svelte';
import {
type ColumnDef,
getCoreRowModel
@@ -12,18 +11,19 @@
data: TData[];
};
let {
data,
columns
}: DataTableProps<TData, TValue> = $props();
let { data, columns }: DataTableProps<TData, TValue> = $props();
const table = createSvelteTable({
get data() {
return data;
},
columns,
getCoreRowModel: getCoreRowModel()
});
/* =========================
TABLA REACTIVA (RUNES)
========================= */
const table = $derived(
createSvelteTable({
data,
columns,
getCoreRowModel: getCoreRowModel()
})
);
</script>
<div class="w-full">
@@ -45,9 +45,10 @@
</Table.Row>
{/each}
</Table.Header>
<Table.Body>
{#each table.getRowModel().rows as row (row.id)}
<Table.Row data-state={row.getIsSelected() && "selected"}>
<Table.Row>
{#each row.getVisibleCells() as cell (cell.id)}
<Table.Cell>
<FlexRender

View File

@@ -1,285 +1,291 @@
<script lang="ts">
import { onMount } from 'svelte';
import { customsBrokersApi, type CustomsBroker } from '$lib/api/dashboard/a76/customs-brokers';
import DataTable from '$lib/components/dashboard/customs_brokers/data-table.svelte';
import { createColumns } from '$lib/components/dashboard/customs_brokers/columns.js';
import CreateDialog from '$lib/components/dashboard/customs_brokers/create-dialog.svelte';
import * as Card from '$lib/components/ui/card';
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import type { PageData } from './$types';
import { browser } from '$app/environment';
import { companyStore } from '$lib/stores/company.svelte';
import { Plus, Search, Trash2 } from 'lucide-svelte';
import { onMount } from 'svelte';
import { customsBrokersApi, type CustomsBroker } from '$lib/api/dashboard/a76/customs-brokers';
import DataTable from '$lib/components/dashboard/customs_brokers/data-table.svelte';
import { createColumns } from '$lib/components/dashboard/customs_brokers/columns.js';
import CreateDialog from '$lib/components/dashboard/customs_brokers/create-dialog.svelte';
import * as Card from '$lib/components/ui/card';
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import type { PageData } from './$types';
import { browser } from '$app/environment';
import { companyStore } from '$lib/stores/company.svelte';
import { Plus, Search, Trash2, Loader2 } from 'lucide-svelte';
// Los datos iniciales vienen del servidor
let { data }: { data: PageData } = $props();
// Estado para el diálogo de crear
let showCreateDialog = $state(false);
// Los datos iniciales vienen del servidor
let { data }: { data: PageData } = $props();
// Estado para el diálogo de crear
let showCreateDialog = $state(false);
// Estado para la lista de agentes aduanales (inicializar con datos del servidor)
let brokersList = $state<CustomsBroker[]>(data.brokers || []);
let listLoading = $state(false);
let listError = $state<string | null>(data.error || null);
// Estado para la lista de agentes aduanales
let brokersList = $state<CustomsBroker[]>(data.brokers || []);
let listLoading = $state(false);
let listError = $state<string | null>(data.error || null);
// Estado para búsqueda
let searchKey = $state('');
let searchedBroker = $state<CustomsBroker | null>(null);
let searchLoading = $state(false);
let searchError = $state<string | null>(null);
// Estado para búsqueda
let searchKey = $state('');
let searchedBroker = $state<CustomsBroker | null>(null);
let searchLoading = $state(false);
let searchError = $state<string | null>(null);
// Sincronizar token de cookies a localStorage al montar el componente
onMount(() => {
if (browser) {
// Función para obtener el valor de una cookie
const getCookie = (name: string): string | null => {
const value = `; ${document.cookie}`;
const parts = value.split(`; ${name}=`);
if (parts.length === 2) return parts.pop()?.split(';').shift() || null;
return null;
};
// Control para evitar bucles de recarga
let lastLoadedCompanyId = $state<string>("");
// Verificar si hay token en las cookies
const cookieToken = getCookie('access_token');
const localToken = localStorage.getItem('access_token');
// --- REACTIVIDAD AUTOMÁTICA ---
// 1. Sincronizar si cambia la data del servidor (navegación)
$effect(() => {
if (data.brokers) {
brokersList = data.brokers;
if (data.currentCompanyId) {
lastLoadedCompanyId = data.currentCompanyId.toString();
}
}
});
if (cookieToken && cookieToken !== localToken) {
localStorage.setItem('access_token', cookieToken);
}
// 2. Sincronizar si cambia el store de la empresa
$effect(() => {
const currentId = companyStore.activeCompany?.id?.toString();
// Solo recargamos si hay ID, si es diferente al último y estamos en el navegador
if (browser && currentId && currentId !== lastLoadedCompanyId) {
lastLoadedCompanyId = currentId;
// Recarga normal (false), muestra loading porque cambiamos de empresa
reloadData(false);
}
});
// También sincronizar refresh_token si existe
const cookieRefreshToken = getCookie('refresh_token');
const localRefreshToken = localStorage.getItem('refresh_token');
onMount(() => {
if (browser) {
const getCookie = (name: string): string | null => {
const value = `; ${document.cookie}`;
const parts = value.split(`; ${name}=`);
if (parts.length === 2) return parts.pop()?.split(';').shift() || null;
return null;
};
if (cookieRefreshToken && cookieRefreshToken !== localRefreshToken) {
localStorage.setItem('refresh_token', cookieRefreshToken);
}
const cookieToken = getCookie('access_token');
const localToken = localStorage.getItem('access_token');
if (cookieToken && cookieToken !== localToken) localStorage.setItem('access_token', cookieToken);
// Escuchar cambios de compañía
const handleCompanyChange = (event: CustomEvent) => {
// Recargar la página para obtener datos de la nueva compañía
reloadData();
};
const cookieRefreshToken = getCookie('refresh_token');
const localRefreshToken = localStorage.getItem('refresh_token');
if (cookieRefreshToken && cookieRefreshToken !== localRefreshToken) localStorage.setItem('refresh_token', cookieRefreshToken);
}
});
window.addEventListener('companyChanged', handleCompanyChange as EventListener);
async function handleSearch() {
if (!searchKey.trim()) {
searchError = 'Por favor ingresa una clave de agente aduanal';
return;
}
// Cleanup
return () => {
window.removeEventListener('companyChanged', handleCompanyChange as EventListener);
};
}
});
searchLoading = true;
searchError = null;
searchedBroker = null;
async function handleSearch() {
if (!searchKey.trim()) {
searchError = 'Por favor ingresa una clave de agente aduanal';
return;
}
try {
const companyId = companyStore.activeCompany?.id.toString() || '';
const response = await customsBrokersApi.get(searchKey.trim(), companyId);
searchLoading = true;
searchError = null;
searchedBroker = null;
if (response.error) {
if (response.status === 404) {
searchError = 'No se encontró un agente aduanal con esta clave';
} else if (response.status === 401 || response.status === 403) {
searchError = 'Sesión expirada. Recargando página...';
setTimeout(() => window.location.reload(), 2000);
} else {
searchError = response.error;
}
return;
}
try {
const response = await customsBrokersApi.get(searchKey.trim());
if (response.data) {
searchedBroker = response.data;
}
} catch (e) {
searchError = 'Error al buscar agente aduanal';
console.error('Error searching broker:', e);
} finally {
searchLoading = false;
}
}
if (response.error) {
if (response.status === 404) {
searchError = 'No se encontró un agente aduanal con esta clave';
} else if (response.status === 401 || response.status === 403) {
searchError = 'Sesión expirada. Recargando página...';
setTimeout(() => {
window.location.reload();
}, 2000);
} else {
searchError = response.error;
}
return;
}
/**
* Recarga los datos.
* @param silent Si es true, NO bloquea la tabla con el spinner (ideal para delete/create).
*/
async function reloadData(silent = false) {
searchKey = '';
searchedBroker = null;
searchError = null;
if (!companyStore.activeCompany) return;
// Solo mostramos el spinner visual si NO es silencioso
if (!silent) {
listLoading = true;
}
listError = null;
if (response.data) {
searchedBroker = response.data;
}
} catch (e) {
searchError = 'Error al buscar agente aduanal';
console.error('Error searching broker:', e);
} finally {
searchLoading = false;
}
}
try {
const response = await customsBrokersApi.list(companyStore.activeCompany.id.toString());
async function reloadData() {
// Limpiar búsqueda
searchKey = '';
searchedBroker = null;
searchError = null;
// Recargar lista de brokers desde la API
if (!companyStore.activeCompany) return;
listLoading = true;
listError = null;
if (response.error) {
console.error('📊 [CustomsBrokers] Error en reloadData:', response.error);
if (response.status === 401 || response.status === 403) {
listError = 'Sesión expirada. Recargando página...';
setTimeout(() => window.location.reload(), 2000);
} else {
if (!silent) listError = response.error;
}
return;
}
try {
const response = await customsBrokersApi.list(companyStore.activeCompany.id.toString());
if (response.data) {
brokersList = response.data;
}
} catch (e) {
listError = 'Error recargando datos';
console.error('📊 [CustomsBrokers] Error reloading:', e);
} finally {
listLoading = false;
}
}
if (response.error) {
console.error('📊 [CustomsBrokers] Error en reloadData:', response.error);
if (response.status === 401 || response.status === 403) {
listError = 'Sesión expirada. Recargando página...';
setTimeout(() => {
window.location.reload();
}, 2000);
} else {
listError = response.error;
}
return;
}
function handleCreateClick() {
showCreateDialog = true;
}
if (response.data) {
// Reemplazar la lista con los nuevos datos
brokersList = response.data;
}
} catch (e) {
listError = 'Error recargando datos';
console.error('📊 [CustomsBrokers] Error reloading:', e);
} finally {
listLoading = false;
}
}
function handleSuccess() {
// Esperamos 300ms a la DB y recargamos "en silencio" (sin bloquear la tabla)
setTimeout(() => {
reloadData(true);
}, 300);
}
function handleCreateClick() {
showCreateDialog = true;
}
function handleSuccess() {
// Recargar datos después de crear/eliminar
reloadData();
}
// Crear columnas con el callback onSuccess
const columns = createColumns(handleSuccess);
// Array para mostrar en la tabla (búsqueda o lista completa)
const brokers = $derived(searchedBroker ? [searchedBroker] : brokersList);
const columns = createColumns(handleSuccess);
const brokers = $derived(searchedBroker ? [searchedBroker] : brokersList);
</script>
<div class="space-y-6">
<!-- Header -->
<div class="flex items-center justify-between">
<div>
<h1 class="text-3xl font-bold tracking-tight">Agentes Aduanales</h1>
<p class="text-muted-foreground">
Gestiona el catálogo de agentes aduanales
</p>
</div>
<Button href="/dashboard/customs_brokers/new" >
<Plus class="mr-2" size={16} />
Nuevo Agente Aduanal
</Button>
</div>
<div class="flex items-center justify-between">
<div>
<h1 class="text-3xl font-bold tracking-tight">Agentes Aduanales</h1>
<p class="text-muted-foreground">
Gestiona el catálogo de agentes aduanales
</p>
</div>
<Button href="/dashboard/customs_brokers/edit" >
<Plus class="mr-2" size={16} />
Nuevo Agente Aduanal
</Button>
</div>
<!-- Búsqueda -->
<Card.Root>
<Card.Header>
<Card.Title>Buscar Agente Aduanal</Card.Title>
<Card.Description>
Ingresa la clave del agente aduanal para buscarlo
{#if companyStore.activeCompany}
- Compañía: {companyStore.activeCompany.name}
{/if}
</Card.Description>
</Card.Header>
<Card.Content>
<form onsubmit={(e) => { e.preventDefault(); handleSearch(); }} class="space-y-4">
<div class="flex gap-4">
<div class="flex-1 space-y-2">
<Label for="search-key">Clave del Agente Aduanal</Label>
<Input
id="search-key"
bind:value={searchKey}
placeholder="Ej: 12345"
maxlength={5}
disabled={searchLoading}
/>
</div>
<div class="flex items-end gap-2">
<Button type="submit" disabled={searchLoading}>
{#if searchLoading}
<div class="flex items-center gap-2">
<div class="h-4 w-4 animate-spin rounded-full border-2 border-primary-foreground border-t-transparent"></div>
Buscando...
</div>
{:else}
<Search class="mr-2" size={16} />
Buscar
{/if}
</Button>
{#if searchedBroker}
<Button type="button" variant="outline" onclick={reloadData}>
<Trash2 class="mr-2" size={16} />
Limpiar
</Button>
{/if}
</div>
</div>
<Card.Root>
<Card.Header>
<Card.Title>Buscar Agente Aduanal</Card.Title>
<Card.Description>
Ingresa la clave del agente aduanal para buscarlo
{#if companyStore.activeCompany}
- Compañía: {companyStore.activeCompany.name}
{/if}
</Card.Description>
</Card.Header>
<Card.Content>
<form onsubmit={(e) => { e.preventDefault(); handleSearch(); }} class="space-y-4">
<div class="flex gap-4">
<div class="flex-1 space-y-2">
<Label for="search-key">Clave del Agente Aduanal</Label>
<Input
id="search-key"
bind:value={searchKey}
placeholder="Ej: 12345"
maxlength={5}
disabled={searchLoading}
/>
</div>
<div class="flex items-end gap-2">
<Button type="submit" disabled={searchLoading}>
{#if searchLoading}
<Loader2 class="mr-2 h-4 w-4 animate-spin" />
Buscando...
{:else}
<Search class="mr-2" size={16} />
Buscar
{/if}
</Button>
{#if searchedBroker}
<Button type="button" variant="outline" onclick={() => reloadData(false)}>
<Trash2 class="mr-2" size={16} />
Limpiar
</Button>
{/if}
</div>
</div>
{#if searchError}
<div class="rounded-lg border border-destructive bg-destructive/10 p-3 text-sm text-destructive">
{searchError}
</div>
{/if}
</form>
</Card.Content>
</Card.Root>
{#if searchError}
<div class="rounded-lg border border-destructive bg-destructive/10 p-3 text-sm text-destructive">
{searchError}
</div>
{/if}
</form>
</Card.Content>
</Card.Root>
<!-- Resultados -->
<Card.Root>
<Card.Header>
<div class="flex items-center justify-between">
<div>
<Card.Title>
{#if searchedBroker}
Resultado de la Búsqueda
{:else}
Agentes Aduanales
{/if}
</Card.Title>
<Card.Description>
{#if searchedBroker}
Se encontró 1 agente aduanal
{:else if listLoading}
Cargando agentes aduanales...
{:else}
Total: {brokersList.length} agente{brokersList.length !== 1 ? 's' : ''} aduanal{brokersList.length !== 1 ? 'es' : ''}
{/if}
</Card.Description>
</div>
</div>
</Card.Header>
<Card.Content>
{#if listError}
<div class="rounded-lg border border-destructive bg-destructive/10 p-4 text-sm text-destructive">
{listError}
</div>
{:else if listLoading}
<div class="flex items-center justify-center py-8">
<div class="flex items-center gap-2 text-muted-foreground">
<div class="h-6 w-6 animate-spin rounded-full border-2 border-primary border-t-transparent"></div>
Cargando agentes aduanales...
</div>
</div>
{:else}
<DataTable
data={brokers}
{columns}
/>
{/if}
</Card.Content>
</Card.Root>
<Card.Root>
<Card.Header>
<div class="flex items-center justify-between">
<div>
<Card.Title>
{#if searchedBroker}
Resultado de la Búsqueda
{:else}
Agentes Aduanales
{/if}
</Card.Title>
<Card.Description>
{#if searchedBroker}
Se encontró 1 agente aduanal
{:else}
Total: {brokersList.length} agente{brokersList.length !== 1 ? 's' : ''} aduanal{brokersList.length !== 1 ? 'es' : ''}
{/if}
</Card.Description>
</div>
</div>
</Card.Header>
<Card.Content class="relative min-h-[200px]">
{#if listError}
<div class="rounded-lg border border-destructive bg-destructive/10 p-4 text-sm text-destructive">
{listError}
</div>
{:else}
{#if listLoading}
<div class="absolute inset-0 z-10 flex items-center justify-center bg-background/60 backdrop-blur-[1px] rounded-lg transition-all duration-300">
<div class="flex items-center gap-2 rounded-full bg-background px-4 py-2 shadow-lg border animate-in fade-in zoom-in duration-300">
<Loader2 class="h-4 w-4 animate-spin text-primary" />
<span class="text-sm font-medium">Actualizando...</span>
</div>
</div>
{/if}
<div class={listLoading ? 'opacity-50 transition-opacity duration-300' : 'transition-opacity duration-300'}>
{#key brokers}
<DataTable
data={brokers}
{columns}
/>
{/key}
</div>
{/if}
</Card.Content>
</Card.Root>
</div>
<!-- Diálogo de crear -->
<CreateDialog bind:open={showCreateDialog} onSuccess={handleSuccess} />
<CreateDialog bind:open={showCreateDialog} onSuccess={handleSuccess} />

View File

@@ -0,0 +1,275 @@
<script lang="ts">
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { companyStore } from '$lib/stores/company.svelte';
import { customsBrokersApi, type CreateCustomsBrokerData } from "$lib/api/dashboard/a76/customs-brokers";
// UI Components
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import { Separator } from "$lib/components/ui/separator";
import { ArrowLeft, Loader2, Save, User, Phone, MapPin } from 'lucide-svelte';
// --- 1. LÓGICA DE IDENTIFICACIÓN ---
let routeId = $derived($page.params.id);
let isEdit = $derived(!!routeId && routeId !== 'new');
let title = $derived(isEdit ? "Editar Agente Aduanal" : "Nuevo Agente Aduanal");
// --- 2. ESTADO ---
let loading = $state(false);
let activeTab = $state('general');
let error = $state<string | null>(null);
let dataLoaded = $state(false);
let formData = $state<CreateCustomsBrokerData>({
broker_key: "",
license: "",
name: "",
tax_id: "",
email: "",
phone: "",
fax: "",
contact: "",
address: "",
postal_code: "",
city: "",
state: "",
country: "MEX",
type: "",
personal_id: "",
position: "",
company: "",
company_id: ""
});
// --- 3. CARGA DE DATOS REACTIVA ---
$effect(() => {
const company = companyStore.activeCompany;
if (company && isEdit && routeId && !dataLoaded && !loading) {
loadBrokerData(routeId, company.id.toString());
} else if (company && !isEdit) {
formData.company_id = company.id.toString();
}
});
async function loadBrokerData(key: string, cId: string) {
if (!key || key === 'undefined') return;
loading = true;
try {
const res = await customsBrokersApi.get(key, cId);
if (res.data) {
const d = res.data;
// Mapeo exhaustivo para asegurar reactividad
formData = {
broker_key: d.broker_key || "",
license: d.license || "",
name: d.name || "",
tax_id: d.tax_id || "",
email: d.email || "",
phone: d.phone || "",
fax: d.fax || "",
contact: d.contact || "",
address: d.address || "",
postal_code: d.postal_code || "",
city: d.city || "",
state: d.state || "",
country: d.country || "MEX",
type: d.type || "",
personal_id: d.personal_id || "",
position: d.position || "",
company: d.company || "",
company_id: cId
};
dataLoaded = true;
} else if (res.error) {
error = res.error;
}
} catch (e) {
error = "Error al conectar con el servidor";
} finally {
loading = false;
}
}
// --- 4. GUARDADO ---
async function handleSave() {
if (!formData.broker_key?.trim() || !formData.license?.trim()) {
error = "Clave y Patente son obligatorios";
return;
}
loading = true;
error = null;
try {
const cId = companyStore.activeCompany?.id.toString() || "";
const res = isEdit
? await customsBrokersApi.update(routeId!, formData, cId)
: await customsBrokersApi.create(formData, cId);
if (res.error) throw new Error(res.error);
goto('/dashboard/customs_brokers');
} catch (e: any) {
error = e.message || "Error al procesar la solicitud";
} finally {
loading = false;
}
}
</script>
<div class="w-full mx-auto max-w-5xl py-6 px-4 pb-40">
<div class="flex items-center gap-4 mb-8">
<Button variant="outline" size="icon" href="/dashboard/customs_brokers">
<ArrowLeft class="h-4 w-4" />
</Button>
<div>
<h1 class="text-3xl font-bold tracking-tight">{title}</h1>
<p class="text-muted-foreground text-sm">
{isEdit ? `Modificando registro: ${routeId}` : 'Completa la información para el nuevo Agente.'}
</p>
</div>
</div>
{#if error}
<div class="bg-destructive/10 text-destructive p-4 rounded-lg mb-6 border border-destructive/20 flex items-center gap-2 animate-in fade-in zoom-in duration-200">
<span class="text-lg">⚠️</span> {error}
</div>
{/if}
<div class="bg-card border rounded-2xl shadow-sm overflow-hidden">
<div class="flex bg-muted/50 p-1 gap-1 border-b">
{#each [
{ id: 'general', label: 'Identificación', icon: User },
{ id: 'contacto', label: 'Contacto', icon: Phone },
{ id: 'direccion', label: 'Dirección', icon: MapPin }
] as tab}
<button
type="button"
class="flex items-center justify-center gap-2 flex-1 py-2.5 text-sm font-medium rounded-lg transition-all
{activeTab === tab.id
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:bg-background/50 hover:text-foreground'}"
onclick={() => activeTab = tab.id}
>
<tab.icon class="h-4 w-4" />
{tab.label}
</button>
{/each}
</div>
<div class="p-8">
<form onsubmit={(e) => { e.preventDefault(); handleSave(); }}>
{#if activeTab === 'general'}
<div class="space-y-6 animate-in fade-in slide-in-from-bottom-2 duration-300">
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div class="grid gap-2">
<Label class="required">Clave Agente</Label>
<Input bind:value={formData.broker_key} placeholder="Ej. 550" disabled={isEdit || loading} />
</div>
<div class="grid gap-2">
<Label class="required">Patente</Label>
<Input bind:value={formData.license} placeholder="Ej. 3421" disabled={loading} />
</div>
</div>
<div class="grid gap-2">
<Label>Nombre / Razón Social</Label>
<Input bind:value={formData.name} placeholder="Nombre oficial" disabled={loading} />
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div class="grid gap-2">
<Label>RFC</Label>
<Input bind:value={formData.tax_id} placeholder="RFC de la empresa" disabled={loading} />
</div>
<div class="grid gap-2">
<Label>CURP</Label>
<Input bind:value={formData.personal_id} placeholder="CURP si aplica" disabled={loading} />
</div>
</div>
</div>
{:else if activeTab === 'contacto'}
<div class="space-y-6 animate-in fade-in slide-in-from-bottom-2 duration-300">
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div class="grid gap-2">
<Label>Persona de Contacto</Label>
<Input bind:value={formData.contact} placeholder="Nombre del contacto" disabled={loading} />
</div>
<div class="grid gap-2">
<Label>Puesto / Cargo</Label>
<Input bind:value={formData.position} placeholder="Ej. Gerente Comercial" disabled={loading} />
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
<div class="grid gap-2">
<Label>Teléfono</Label>
<Input bind:value={formData.phone} placeholder="656-000-0000" disabled={loading} />
</div>
<div class="grid gap-2">
<Label>Fax</Label>
<Input bind:value={formData.fax} disabled={loading} />
</div>
<div class="grid gap-2">
<Label>Correo Electrónico</Label>
<Input type="email" bind:value={formData.email} placeholder="correo@empresa.com" disabled={loading} />
</div>
</div>
</div>
{:else if activeTab === 'direccion'}
<div class="space-y-6 animate-in fade-in slide-in-from-bottom-2 duration-300">
<div class="grid gap-2">
<Label>Calle y Número</Label>
<Input bind:value={formData.address} placeholder="Dirección completa" disabled={loading} />
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
<div class="grid gap-2">
<Label>C.P.</Label>
<Input bind:value={formData.postal_code} placeholder="32000" disabled={loading} />
</div>
<div class="grid gap-2 md:col-span-2">
<Label>Ciudad</Label>
<Input bind:value={formData.city} placeholder="Ciudad" disabled={loading} />
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div class="grid gap-2">
<Label>Estado</Label>
<Input bind:value={formData.state} placeholder="Estado" disabled={loading} />
</div>
<div class="grid gap-2">
<Label>País</Label>
<Input bind:value={formData.country} placeholder="MEX" disabled={loading} />
</div>
</div>
</div>
{/if}
<div class="fixed bottom-0 left-0 right-0 md:left-64 bg-background/95 backdrop-blur-sm border-t p-4 z-50">
<div class="max-w-5xl mx-auto flex justify-end items-center gap-4">
<Button variant="ghost" href="/dashboard/customs_brokers" disabled={loading}>
Cancelar
</Button>
<Button type="submit" disabled={loading} class="min-w-[150px] shadow-lg shadow-primary/20">
{#if loading}
<Loader2 class="mr-2 h-4 w-4 animate-spin" />
Guardando...
{:else}
<Save class="mr-2 h-4 w-4" />
{isEdit ? 'Actualizar Agente' : 'Guardar Agente'}
{/if}
</Button>
</div>
</div>
</form>
</div>
</div>
</div>
<style>
:global(.required::after) {
content: " *";
color: hsl(var(--destructive));
font-weight: bold;
}
</style>