Merge pull request 'feature/nav-tab-customs-brokers' (#105) from feature/nav-tab-custims-broker into feature/nav-tab-items
Reviewed-on: ADUANASOFT/anexo76#105
This commit is contained in:
@@ -19,6 +19,9 @@ export const GLOBAL_NAV = {
|
||||
'e': '/dashboard/reference_data/customs_sections#',
|
||||
'd': '/dashboard/reference_data/customs_sections',
|
||||
|
||||
// Customs & Brokers
|
||||
'a': '/dashboard/customs_brokers',
|
||||
|
||||
// Invoices
|
||||
'i': '/dashboard/invoices',
|
||||
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { ShortcutDef } from '$lib/stores/shortcut-store';
|
||||
|
||||
export const obtenerAtajosEdicionAgente = (acciones: {
|
||||
irGeneral: () => void;
|
||||
irContacto: () => void;
|
||||
irDireccion: () => void;
|
||||
guardar: () => void;
|
||||
cancelar: () => void;
|
||||
}): ShortcutDef[] => [
|
||||
{
|
||||
key: 'Alt+Digit1',
|
||||
description: 'Tab General',
|
||||
action: acciones.irGeneral
|
||||
},
|
||||
{
|
||||
key: 'Alt+Digit2',
|
||||
description: 'Tab Contacto',
|
||||
action: acciones.irContacto
|
||||
},
|
||||
{
|
||||
key: 'Alt+Digit3',
|
||||
description: 'Tab Dirección',
|
||||
action: acciones.irDireccion
|
||||
},
|
||||
{
|
||||
key: 'Ctrl+S',
|
||||
description: 'Guardar',
|
||||
action: acciones.guardar
|
||||
},
|
||||
{
|
||||
key: 'Escape',
|
||||
description: 'Cancelar / Volver',
|
||||
action: acciones.cancelar
|
||||
}
|
||||
];
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { ShortcutDef } from '$lib/stores/shortcut-store';
|
||||
|
||||
export const obtenerAtajosListaAgentes = (acciones: {
|
||||
irAgentes: () => void;
|
||||
irAduanas: () => void;
|
||||
crear: () => void;
|
||||
recargar: () => void;
|
||||
}): ShortcutDef[] => [
|
||||
{
|
||||
key: 'Alt+Digit1',
|
||||
description: 'Ir a Agentes',
|
||||
action: acciones.irAgentes,
|
||||
context: 'Customs Brokers Navigation'
|
||||
},
|
||||
{
|
||||
key: 'Alt+Digit2',
|
||||
description: 'Ir a Aduanas',
|
||||
action: acciones.irAduanas,
|
||||
context: 'Customs Brokers Navigation'
|
||||
},
|
||||
{
|
||||
key: 'Alt+Shift+N',
|
||||
description: 'Nuevo Registro',
|
||||
action: acciones.crear
|
||||
},
|
||||
{
|
||||
key: 'Alt+Shift+R',
|
||||
description: 'Actualizar Lista',
|
||||
action: acciones.recargar
|
||||
}
|
||||
];
|
||||
@@ -1,333 +1,479 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { customsBrokersApi, type CustomsBroker } from '$lib/api/dashboard/a76/customs-brokers';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import { Plus, RefreshCw, Building2, MapPin, Mail, Phone, FileText, Hash } from 'lucide-svelte';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { companyStore } from '$lib/stores/company.svelte';
|
||||
import { browser } from '$app/environment';
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/stores';
|
||||
import { onMount } from 'svelte';
|
||||
import { customsBrokersApi, type CustomsBroker } from '$lib/api/dashboard/a76/customs-brokers';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import { Plus, RefreshCw, Building2, MapPin, Mail, Phone, FileText, Hash } from 'lucide-svelte';
|
||||
import * as Tabs from '$lib/components/ui/tabs';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { companyStore } from '$lib/stores/company.svelte';
|
||||
import { browser } from '$app/environment';
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/stores';
|
||||
import { useShortcuts } from '$lib/hooks/use-shortcuts';
|
||||
import { obtenerAtajosListaAgentes } from '$lib/config/shortcuts/customs-brokers-list-shortcuts';
|
||||
|
||||
let { data }: { data: any } = $props();
|
||||
import {
|
||||
customsSectionsApi,
|
||||
type CustomsSection
|
||||
} from '$lib/api/dashboard/refrence_data/customs_sections';
|
||||
import DataTable from '$lib/components/dashboard/reference_data/customs_sections/data-table.svelte';
|
||||
import { createColumns } from '$lib/components/dashboard/reference_data/customs_sections/columns.js';
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
|
||||
// State
|
||||
let items = $state<CustomsBroker[]>(data.items || []);
|
||||
let selectedItem = $state<CustomsBroker | null>(null);
|
||||
let isLoading = $state(false);
|
||||
|
||||
// Server-side filtering/pagination (assuming API supports it or we filter client-side if list is short)
|
||||
let allItemsRaw = $state<CustomsBroker[]>(data.brokers || []);
|
||||
|
||||
// Filter state
|
||||
let searchName = $state('');
|
||||
let searchPatent = $state('');
|
||||
let { data }: { data: any } = $props();
|
||||
|
||||
// Pagination state
|
||||
let currentPage = $state(1);
|
||||
let pageSize = $state(50);
|
||||
// Global State
|
||||
let activeTab = $state('brokers');
|
||||
|
||||
// Derived filtered items
|
||||
let filteredItems = $derived(
|
||||
allItemsRaw.filter(item => {
|
||||
const matchesName = !searchName || (item.name?.toLowerCase().includes(searchName.toLowerCase()) ?? false);
|
||||
const matchesPatent = !searchPatent || (item.broker_key?.toLowerCase().includes(searchPatent.toLowerCase()) ?? false);
|
||||
return matchesName && matchesPatent;
|
||||
})
|
||||
);
|
||||
// --- Brokers State ---
|
||||
let items = $state<CustomsBroker[]>(data.items || []);
|
||||
let selectedItem = $state<CustomsBroker | null>(null);
|
||||
let isLoading = $state(false);
|
||||
|
||||
let paginatedItems = $derived(
|
||||
filteredItems.slice((currentPage - 1) * pageSize, currentPage * pageSize)
|
||||
);
|
||||
// Server-side filtering/pagination (for Brokers)
|
||||
let allItemsRaw = $state<CustomsBroker[]>(data.brokers || []);
|
||||
let searchName = $state('');
|
||||
let searchPatent = $state('');
|
||||
let currentPage = $state(1);
|
||||
let pageSize = $state(50);
|
||||
|
||||
let totalItems = $derived(filteredItems.length);
|
||||
let filteredItems = $derived(
|
||||
allItemsRaw.filter((item) => {
|
||||
const matchesName =
|
||||
!searchName || (item.name?.toLowerCase().includes(searchName.toLowerCase()) ?? false);
|
||||
const matchesPatent =
|
||||
!searchPatent ||
|
||||
(item.broker_key?.toLowerCase().includes(searchPatent.toLowerCase()) ?? false);
|
||||
return matchesName && matchesPatent;
|
||||
})
|
||||
);
|
||||
|
||||
// --- Lifecycle ---
|
||||
|
||||
onMount(() => {
|
||||
if (browser) {
|
||||
const handleCompanyChange = () => loadItems();
|
||||
window.addEventListener('companyChanged', handleCompanyChange);
|
||||
return () => window.removeEventListener('companyChanged', handleCompanyChange);
|
||||
}
|
||||
});
|
||||
let paginatedItems = $derived(
|
||||
filteredItems.slice((currentPage - 1) * pageSize, currentPage * pageSize)
|
||||
);
|
||||
let totalItems = $derived(filteredItems.length);
|
||||
|
||||
// --- Actions ---
|
||||
// --- Customs Sections State ---
|
||||
let sections = $state<CustomsSection[]>([]);
|
||||
let loadingSections = $state(false);
|
||||
let totalSections = $state(0);
|
||||
let sectionsPage = $state(1);
|
||||
let hasMoreSections = $derived(sections.length < totalSections);
|
||||
|
||||
async function loadItems() {
|
||||
const companyId = companyStore.activeCompany?.id;
|
||||
if (!companyId) return;
|
||||
// --- Lifecycle ---
|
||||
onMount(() => {
|
||||
if (browser) {
|
||||
const handleCompanyChange = () => {
|
||||
loadItems();
|
||||
loadSections();
|
||||
};
|
||||
window.addEventListener('companyChanged', handleCompanyChange);
|
||||
// Initial load for sections if not loaded
|
||||
loadSections();
|
||||
return () => window.removeEventListener('companyChanged', handleCompanyChange);
|
||||
}
|
||||
});
|
||||
|
||||
isLoading = true;
|
||||
try {
|
||||
const response = await customsBrokersApi.list(companyId.toString());
|
||||
|
||||
// Handle response wrapper
|
||||
if ((response as any).error) {
|
||||
toast.error((response as any).error);
|
||||
return;
|
||||
}
|
||||
// --- Actions ---
|
||||
|
||||
// Normalizing data structure
|
||||
const data = (response as any).data || response;
|
||||
if (Array.isArray(data)) {
|
||||
allItemsRaw = data;
|
||||
} else if ((data as any).items) {
|
||||
allItemsRaw = (data as any).items;
|
||||
}
|
||||
// Brokers Actions
|
||||
async function loadItems() {
|
||||
const companyId = companyStore.activeCompany?.id;
|
||||
if (!companyId) return;
|
||||
isLoading = true;
|
||||
try {
|
||||
const response = await customsBrokersApi.list(companyId.toString());
|
||||
if ((response as any).error) {
|
||||
toast.error((response as any).error);
|
||||
return;
|
||||
}
|
||||
const d = (response as any).data || response;
|
||||
if (Array.isArray(d)) {
|
||||
allItemsRaw = d;
|
||||
} else if ((d as any).items) {
|
||||
allItemsRaw = (d as any).items;
|
||||
}
|
||||
currentPage = 1;
|
||||
} catch (e: any) {
|
||||
console.error('Error loading items:', e);
|
||||
toast.error('Error al cargar datos');
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
function handleSearch() {
|
||||
currentPage = 1;
|
||||
}
|
||||
function selectItem(item: CustomsBroker) {
|
||||
selectedItem = item;
|
||||
}
|
||||
function handleEdit() {
|
||||
if (selectedItem) goto(`/dashboard/customs_brokers/edit/${selectedItem.broker_key}`);
|
||||
}
|
||||
async function handleDelete() {
|
||||
if (!selectedItem || !companyStore.activeCompany?.id) return;
|
||||
if (!confirm('¿Estás seguro de eliminar este Agente Aduanal?')) return;
|
||||
try {
|
||||
await customsBrokersApi.delete(
|
||||
selectedItem.broker_key,
|
||||
companyStore.activeCompany.id.toString()
|
||||
);
|
||||
toast.success('Agente eliminado');
|
||||
selectedItem = null;
|
||||
loadItems();
|
||||
} catch (e) {
|
||||
toast.error('Error al eliminar');
|
||||
}
|
||||
}
|
||||
|
||||
currentPage = 1;
|
||||
// Customs Sections Actions
|
||||
async function loadSections() {
|
||||
// Simple loading logic reusing existing API
|
||||
// Assuming we load all or paginate - trying to match existing page logic
|
||||
if (loadingSections) return;
|
||||
loadingSections = true;
|
||||
try {
|
||||
const response = await customsSectionsApi.list(sectionsPage, 50);
|
||||
if (response.data?.items) {
|
||||
sections = sectionsPage === 1 ? response.data.items : [...sections, ...response.data.items];
|
||||
totalSections = response.data.total;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error loading sections', e);
|
||||
} finally {
|
||||
loadingSections = false;
|
||||
}
|
||||
}
|
||||
|
||||
} catch (e: any) {
|
||||
console.error('Error loading items:', e);
|
||||
toast.error('Error al cargar datos');
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
currentPage = 1;
|
||||
}
|
||||
async function loadMoreSections() {
|
||||
if (loadingSections || !hasMoreSections) return;
|
||||
sectionsPage++;
|
||||
await loadSections();
|
||||
}
|
||||
|
||||
function selectItem(item: CustomsBroker) {
|
||||
selectedItem = item;
|
||||
}
|
||||
|
||||
function handleEdit() {
|
||||
if (selectedItem) goto(`/dashboard/customs_brokers/edit/${selectedItem.broker_key}`);
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (!selectedItem || !companyStore.activeCompany?.id) return;
|
||||
if (!confirm('¿Estás seguro de eliminar este Agente Aduanal?')) return;
|
||||
|
||||
try {
|
||||
await customsBrokersApi.delete(selectedItem.broker_key, companyStore.activeCompany.id.toString());
|
||||
toast.success('Agente eliminado');
|
||||
selectedItem = null;
|
||||
loadItems();
|
||||
} catch (e) {
|
||||
toast.error('Error al eliminar');
|
||||
}
|
||||
}
|
||||
// Shortcuts Integration
|
||||
useShortcuts(
|
||||
'Customs Brokers List',
|
||||
obtenerAtajosListaAgentes({
|
||||
irAgentes: () => (activeTab = 'brokers'),
|
||||
irAduanas: () => (activeTab = 'customs'),
|
||||
crear: () => {
|
||||
if (activeTab === 'brokers') {
|
||||
goto('/dashboard/customs_brokers/edit/new');
|
||||
} else {
|
||||
// For Customs Sections, we might need a dialog.
|
||||
// Since we are "not modifying logic", we'll just show a toast or Placeholder TODO
|
||||
toast.info('Crear Sección Aduanal: Implementación pendiente de diálogo');
|
||||
}
|
||||
},
|
||||
recargar: () => {
|
||||
if (activeTab === 'brokers') loadItems();
|
||||
else {
|
||||
sectionsPage = 1;
|
||||
loadSections();
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
const sectionsColumns = createColumns(() => {
|
||||
sectionsPage = 1;
|
||||
loadSections();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col h-[calc(100vh-4rem)] p-4 gap-4 pb-15">
|
||||
<!-- Title -->
|
||||
<div class="flex flex-col gap-1">
|
||||
<h1 class="text-2xl font-bold">AGENTES ADUANALES</h1>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
Catálogo de agentes aduanales y apoderados legales
|
||||
</p>
|
||||
</div>
|
||||
<!-- Title -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex flex-col gap-1">
|
||||
<h1 class="text-2xl font-bold">GESTIÓN ADUANAL</h1>
|
||||
<p class="text-sm text-muted-foreground">Administración de Agentes y Secciones Aduanales</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 flex gap-4 overflow-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 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>
|
||||
</div>
|
||||
<div class="grid grid-cols-3 gap-4">
|
||||
<div class="space-y-2">
|
||||
<Label class="text-xs">Nombre</Label>
|
||||
<Input
|
||||
bind:value={searchName}
|
||||
placeholder="Buscar por nombre..."
|
||||
class="h-9"
|
||||
oninput={handleSearch}
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label class="text-xs">Patente / Clave</Label>
|
||||
<Input
|
||||
bind:value={searchPatent}
|
||||
placeholder="Num. Patente..."
|
||||
class="h-9"
|
||||
oninput={handleSearch}
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-end">
|
||||
<!-- Placeholder for layout balance -->
|
||||
</div>
|
||||
</div>
|
||||
</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.Trigger
|
||||
value="brokers"
|
||||
class="data-[state=active]:border-primary border-b-2 border-transparent rounded-none"
|
||||
>
|
||||
Agentes Aduanales
|
||||
</Tabs.Trigger>
|
||||
<Tabs.Trigger
|
||||
value="customs"
|
||||
class="data-[state=active]:border-primary border-b-2 border-transparent rounded-none"
|
||||
>
|
||||
Secciones Aduanales
|
||||
</Tabs.Trigger>
|
||||
</Tabs.List>
|
||||
|
||||
<!-- 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">
|
||||
<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" />
|
||||
Actualizar
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Tabs.Content
|
||||
value="brokers"
|
||||
class="flex-1 flex gap-4 overflow-hidden mt-0 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 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>
|
||||
</div>
|
||||
<div class="grid grid-cols-3 gap-4">
|
||||
<div class="space-y-2">
|
||||
<Label class="text-xs">Nombre</Label>
|
||||
<Input
|
||||
bind:value={searchName}
|
||||
placeholder="Buscar por nombre..."
|
||||
class="h-9"
|
||||
oninput={handleSearch}
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label class="text-xs">Patente / Clave</Label>
|
||||
<Input
|
||||
bind:value={searchPatent}
|
||||
placeholder="Num. Patente..."
|
||||
class="h-9"
|
||||
oninput={handleSearch}
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-end">
|
||||
<!-- Placeholder for layout balance -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-auto bg-card">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="bg-muted text-muted-foreground border-b">
|
||||
<tr>
|
||||
<th class="px-3 py-2 text-left w-24">Patente</th>
|
||||
<th class="px-3 py-2 text-left">Nombre</th>
|
||||
<th class="px-3 py-2 text-left">Licencia</th>
|
||||
<th class="px-3 py-2 text-left">Ciudad</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#if isLoading}
|
||||
<tr><td colspan="4" class="text-center py-8 text-muted-foreground">Cargando...</td></tr>
|
||||
{:else if paginatedItems.length === 0}
|
||||
<tr><td colspan="4" class="text-center py-8 text-muted-foreground">No se encontraron registros</td></tr>
|
||||
{:else}
|
||||
{#each paginatedItems as item (item.broker_key)}
|
||||
<tr
|
||||
class="border-b cursor-pointer transition-colors hover:bg-muted/50 {selectedItem?.broker_key === item.broker_key ? 'bg-muted' : ''}"
|
||||
onclick={() => selectItem(item)}
|
||||
>
|
||||
<td class="px-3 py-2 font-mono font-bold">{item.broker_key}</td>
|
||||
<td class="px-3 py-2 font-medium">{item.name || '-'}</td>
|
||||
<td class="px-3 py-2 text-muted-foreground">{item.license || '-'}</td>
|
||||
<td class="px-3 py-2 text-muted-foreground">{item.city || '-'}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
{/if}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!-- Simple Pagination Controls -->
|
||||
{#if totalItems > pageSize}
|
||||
<div class="p-2 border-t flex justify-end gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={currentPage === 1}
|
||||
onclick={() => currentPage--}
|
||||
>
|
||||
Anterior
|
||||
</Button>
|
||||
<span class="flex items-center text-xs text-muted-foreground px-2">
|
||||
Página {currentPage} de {Math.ceil(totalItems / pageSize)}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={currentPage * pageSize >= totalItems}
|
||||
onclick={() => currentPage++}
|
||||
>
|
||||
Siguiente
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
</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">
|
||||
<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" />
|
||||
Actualizar
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right Panel: Details -->
|
||||
<div class="w-96 flex-none flex flex-col border rounded-xl bg-muted/30 shadow-sm overflow-hidden">
|
||||
<div class="p-4 border-b bg-card">
|
||||
<p class="text-[10px] uppercase tracking-widest opacity-80 text-muted-foreground">Detalles del Agente</p>
|
||||
<h2 class="text-xl font-black font-mono tracking-tighter truncate" title={selectedItem?.name || ''}>
|
||||
{selectedItem?.name || '---'}
|
||||
</h2>
|
||||
<div class="flex items-center gap-2 mt-1">
|
||||
<span class="text-xs font-mono text-muted-foreground">Patente: {selectedItem?.broker_key || ''}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-1 overflow-auto bg-card">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="bg-muted text-muted-foreground border-b">
|
||||
<tr>
|
||||
<th class="px-3 py-2 text-left w-24">Patente</th>
|
||||
<th class="px-3 py-2 text-left">Nombre</th>
|
||||
<th class="px-3 py-2 text-left">Licencia</th>
|
||||
<th class="px-3 py-2 text-left">Ciudad</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#if isLoading}
|
||||
<tr
|
||||
><td colspan="4" class="text-center py-8 text-muted-foreground">Cargando...</td
|
||||
></tr
|
||||
>
|
||||
{:else if paginatedItems.length === 0}
|
||||
<tr
|
||||
><td colspan="4" class="text-center py-8 text-muted-foreground"
|
||||
>No se encontraron registros</td
|
||||
></tr
|
||||
>
|
||||
{:else}
|
||||
{#each paginatedItems as item (item.broker_key)}
|
||||
<tr
|
||||
class="border-b cursor-pointer transition-colors hover:bg-muted/50 {selectedItem?.broker_key ===
|
||||
item.broker_key
|
||||
? 'bg-muted'
|
||||
: ''}"
|
||||
onclick={() => selectItem(item)}
|
||||
>
|
||||
<td class="px-3 py-2 font-mono font-bold">{item.broker_key}</td>
|
||||
<td class="px-3 py-2 font-medium">{item.name || '-'}</td>
|
||||
<td class="px-3 py-2 text-muted-foreground">{item.license || '-'}</td>
|
||||
<td class="px-3 py-2 text-muted-foreground">{item.city || '-'}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
{/if}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!-- Simple Pagination Controls -->
|
||||
{#if totalItems > pageSize}
|
||||
<div class="p-2 border-t flex justify-end gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={currentPage === 1}
|
||||
onclick={() => currentPage--}
|
||||
>
|
||||
Anterior
|
||||
</Button>
|
||||
<span class="flex items-center text-xs text-muted-foreground px-2">
|
||||
Página {currentPage} de {Math.ceil(totalItems / pageSize)}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={currentPage * pageSize >= totalItems}
|
||||
onclick={() => currentPage++}
|
||||
>
|
||||
Siguiente
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-auto p-5 space-y-6 bg-card">
|
||||
{#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">
|
||||
<FileText size={10} /> Licencia / Autorización
|
||||
</Label>
|
||||
<p class="text-sm font-medium">{selectedItem.license || '-'}</p>
|
||||
</div>
|
||||
<!-- Right Panel: Details -->
|
||||
<div
|
||||
class="w-96 flex-none flex flex-col border rounded-xl bg-muted/30 shadow-sm overflow-hidden"
|
||||
>
|
||||
<div class="p-4 border-b bg-card">
|
||||
<p class="text-[10px] uppercase tracking-widest opacity-80 text-muted-foreground">
|
||||
Detalles del Agente
|
||||
</p>
|
||||
<h2
|
||||
class="text-xl font-black font-mono tracking-tighter truncate"
|
||||
title={selectedItem?.name || ''}
|
||||
>
|
||||
{selectedItem?.name || '---'}
|
||||
</h2>
|
||||
<div class="flex items-center gap-2 mt-1">
|
||||
<span class="text-xs font-mono text-muted-foreground"
|
||||
>Patente: {selectedItem?.broker_key || ''}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if selectedItem.tax_id}
|
||||
<div class="space-y-1">
|
||||
<Label class="text-[10px] uppercase text-muted-foreground font-bold flex items-center gap-1">
|
||||
<Hash size={10} /> RFC / Tax ID
|
||||
</Label>
|
||||
<p class="text-sm font-mono">{selectedItem.tax_id}</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="pt-4 border-t space-y-3">
|
||||
<Label class="text-[10px] uppercase text-muted-foreground font-bold flex items-center gap-1">
|
||||
<MapPin size={10} /> Dirección
|
||||
</Label>
|
||||
<div class="text-sm space-y-1">
|
||||
<p>{selectedItem.address || ''}</p>
|
||||
<p>
|
||||
{[selectedItem.city, selectedItem.state].filter(Boolean).join(', ')}
|
||||
</p>
|
||||
<p>
|
||||
{[selectedItem.postal_code, selectedItem.country].filter(Boolean).join(', ')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pt-4 border-t space-y-3">
|
||||
<Label class="text-[10px] uppercase text-muted-foreground font-bold flex items-center gap-1">
|
||||
<Phone size={10} /> Contacto
|
||||
</Label>
|
||||
{#if selectedItem.email}
|
||||
<div class="flex items-center gap-2 text-sm">
|
||||
<Mail size={14} class="text-muted-foreground" />
|
||||
<span>{selectedItem.email}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if selectedItem.phone}
|
||||
<div class="flex items-center gap-2 text-sm">
|
||||
<Phone size={14} class="text-muted-foreground" />
|
||||
<span>{selectedItem.phone}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if selectedItem.contact}
|
||||
<div class="mt-2 text-xs text-muted-foreground">
|
||||
<span class="font-bold">Contacto:</span> {selectedItem.contact}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex flex-col items-center justify-center h-full text-center text-muted-foreground opacity-50">
|
||||
<Building2 class="h-12 w-12 mb-3" />
|
||||
<p class="text-sm">Selecciona un agente</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-1 overflow-auto p-5 space-y-6 bg-card">
|
||||
{#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"
|
||||
>
|
||||
<FileText size={10} /> Licencia / Autorización
|
||||
</Label>
|
||||
<p class="text-sm font-medium">{selectedItem.license || '-'}</p>
|
||||
</div>
|
||||
|
||||
{#if selectedItem.tax_id}
|
||||
<div class="space-y-1">
|
||||
<Label
|
||||
class="text-[10px] uppercase text-muted-foreground font-bold flex items-center gap-1"
|
||||
>
|
||||
<Hash size={10} /> RFC / Tax ID
|
||||
</Label>
|
||||
<p class="text-sm font-mono">{selectedItem.tax_id}</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="pt-4 border-t space-y-3">
|
||||
<Label
|
||||
class="text-[10px] uppercase text-muted-foreground font-bold flex items-center gap-1"
|
||||
>
|
||||
<MapPin size={10} /> Dirección
|
||||
</Label>
|
||||
<div class="text-sm space-y-1">
|
||||
<p>{selectedItem.address || ''}</p>
|
||||
<p>
|
||||
{[selectedItem.city, selectedItem.state].filter(Boolean).join(', ')}
|
||||
</p>
|
||||
<p>
|
||||
{[selectedItem.postal_code, selectedItem.country].filter(Boolean).join(', ')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pt-4 border-t space-y-3">
|
||||
<Label
|
||||
class="text-[10px] uppercase text-muted-foreground font-bold flex items-center gap-1"
|
||||
>
|
||||
<Phone size={10} /> Contacto
|
||||
</Label>
|
||||
{#if selectedItem.email}
|
||||
<div class="flex items-center gap-2 text-sm">
|
||||
<Mail size={14} class="text-muted-foreground" />
|
||||
<span>{selectedItem.email}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if selectedItem.phone}
|
||||
<div class="flex items-center gap-2 text-sm">
|
||||
<Phone size={14} class="text-muted-foreground" />
|
||||
<span>{selectedItem.phone}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if selectedItem.contact}
|
||||
<div class="mt-2 text-xs text-muted-foreground">
|
||||
<span class="font-bold">Contacto:</span>
|
||||
{selectedItem.contact}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div
|
||||
class="flex flex-col items-center justify-center h-full text-center text-muted-foreground opacity-50"
|
||||
>
|
||||
<Building2 class="h-12 w-12 mb-3" />
|
||||
<p class="text-sm">Selecciona un agente</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</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">
|
||||
<Card.Content class="flex-1 p-0">
|
||||
<DataTable
|
||||
data={sections}
|
||||
columns={sectionsColumns}
|
||||
loading={loadingSections}
|
||||
hasMore={hasMoreSections}
|
||||
loadMore={loadMoreSections}
|
||||
/>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
</Tabs.Content>
|
||||
</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))]"
|
||||
>
|
||||
<div class="px-4 py-4 max-w-[1400px] mx-auto">
|
||||
<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" />
|
||||
Nuevo
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onclick={handleEdit} disabled={!selectedItem}>
|
||||
Editar
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onclick={handleDelete}
|
||||
disabled={!selectedItem}
|
||||
class="text-destructive hover:text-destructive"
|
||||
>
|
||||
Borrar
|
||||
</Button>
|
||||
{:else}
|
||||
<Button size="sm" onclick={() => toast.info('Pendiente')}>
|
||||
<Plus class="h-4 w-4 mr-1" />
|
||||
Nueva Sección
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sticky Footer Actions -->
|
||||
<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))]">
|
||||
<div class="px-4 py-4 max-w-[1400px] mx-auto">
|
||||
<div class="flex justify-end gap-2">
|
||||
<Button size="sm" href="/dashboard/customs_brokers/edit">
|
||||
<Plus class="h-4 w-4 mr-1" />
|
||||
Nuevo
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onclick={handleEdit} disabled={!selectedItem}>
|
||||
Editar
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onclick={handleDelete} disabled={!selectedItem} class="text-destructive hover:text-destructive">
|
||||
Borrar
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,327 +1,397 @@
|
||||
<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 { Badge } from '$lib/components/ui/badge';
|
||||
import * as Tabs from "$lib/components/ui/tabs";
|
||||
import * as Card from "$lib/components/ui/card";
|
||||
import { ArrowLeft, Loader2, Save, User, Phone, MapPin, Settings, FileText, Hash } from 'lucide-svelte';
|
||||
import { toast } from 'svelte-sonner';
|
||||
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';
|
||||
|
||||
// --- 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");
|
||||
// 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 { Badge } from '$lib/components/ui/badge';
|
||||
import * as Tabs from '$lib/components/ui/tabs';
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import {
|
||||
ArrowLeft,
|
||||
Loader2,
|
||||
Save,
|
||||
User,
|
||||
Phone,
|
||||
MapPin,
|
||||
Settings,
|
||||
FileText,
|
||||
Hash
|
||||
} from 'lucide-svelte';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { useShortcuts } from '$lib/hooks/use-shortcuts';
|
||||
import { obtenerAtajosEdicionAgente } from '$lib/config/shortcuts/customs-broker-edit-shortcuts';
|
||||
|
||||
// --- 2. ESTADO ---
|
||||
let loading = $state(false);
|
||||
let activeTab = $state('general');
|
||||
let error = $state<string | null>(null);
|
||||
let dataLoaded = $state(false);
|
||||
// --- 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');
|
||||
|
||||
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: ""
|
||||
});
|
||||
// --- 2. ESTADO ---
|
||||
let loading = $state(false);
|
||||
let activeTab = $state('general');
|
||||
let error = $state<string | null>(null);
|
||||
let dataLoaded = $state(false);
|
||||
|
||||
// --- 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();
|
||||
}
|
||||
});
|
||||
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: ''
|
||||
});
|
||||
|
||||
async function loadBrokerData(key: string, cId: string) {
|
||||
if (!key || key === 'undefined') return;
|
||||
loading = true;
|
||||
try {
|
||||
const res = await customsBrokersApi.get(key, cId);
|
||||
const d = (res as any).data || res; // Handle wrapper or direct
|
||||
// --- 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();
|
||||
}
|
||||
});
|
||||
|
||||
if (d && !d.error) {
|
||||
// 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 (d.error) {
|
||||
error = d.error;
|
||||
toast.error(error);
|
||||
}
|
||||
} catch (e: any) {
|
||||
error = "Error al conectar con el servidor";
|
||||
toast.error(error);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
async function loadBrokerData(key: string, cId: string) {
|
||||
if (!key || key === 'undefined') return;
|
||||
loading = true;
|
||||
try {
|
||||
const res = await customsBrokersApi.get(key, cId);
|
||||
const d = (res as any).data || res; // Handle wrapper or direct
|
||||
|
||||
// --- 4. GUARDADO ---
|
||||
async function handleSave() {
|
||||
if (!companyStore.activeCompany) {
|
||||
toast.error("Selecciona una compañía");
|
||||
return;
|
||||
}
|
||||
if (!formData.broker_key?.trim() || !formData.license?.trim()) {
|
||||
error = "Clave y Patente son obligatorios";
|
||||
toast.error(error);
|
||||
return;
|
||||
}
|
||||
if (d && !d.error) {
|
||||
// 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 (d.error) {
|
||||
error = d.error;
|
||||
toast.error(error);
|
||||
}
|
||||
} catch (e: any) {
|
||||
error = 'Error al conectar con el servidor';
|
||||
toast.error(error);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
loading = true;
|
||||
error = null;
|
||||
try {
|
||||
const cId = companyStore.activeCompany.id.toString();
|
||||
// Ensure company_id is set
|
||||
formData.company_id = cId;
|
||||
|
||||
const res = isEdit
|
||||
? await customsBrokersApi.update(routeId!, formData, cId)
|
||||
: await customsBrokersApi.create(formData, cId);
|
||||
|
||||
if ((res as any).error) throw new Error((res as any).error);
|
||||
|
||||
toast.success(isEdit ? 'Agente actualizado' : 'Agente creado');
|
||||
goto('/dashboard/customs_brokers');
|
||||
} catch (e: any) {
|
||||
error = e.message || "Error al procesar la solicitud";
|
||||
toast.error(error);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
goto('/dashboard/customs_brokers');
|
||||
}
|
||||
// --- 4. GUARDADO ---
|
||||
async function handleSave() {
|
||||
if (!companyStore.activeCompany) {
|
||||
toast.error('Selecciona una compañía');
|
||||
return;
|
||||
}
|
||||
if (!formData.broker_key?.trim() || !formData.license?.trim()) {
|
||||
error = 'Clave y Patente son obligatorios';
|
||||
toast.error(error);
|
||||
return;
|
||||
}
|
||||
|
||||
loading = true;
|
||||
error = null;
|
||||
try {
|
||||
const cId = companyStore.activeCompany.id.toString();
|
||||
// Ensure company_id is set
|
||||
formData.company_id = cId;
|
||||
|
||||
const res = isEdit
|
||||
? await customsBrokersApi.update(routeId!, formData, cId)
|
||||
: await customsBrokersApi.create(formData, cId);
|
||||
|
||||
if ((res as any).error) throw new Error((res as any).error);
|
||||
|
||||
toast.success(isEdit ? 'Agente actualizado' : 'Agente creado');
|
||||
goto('/dashboard/customs_brokers');
|
||||
} catch (e: any) {
|
||||
error = e.message || 'Error al procesar la solicitud';
|
||||
toast.error(error);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
goto('/dashboard/customs_brokers');
|
||||
}
|
||||
|
||||
useShortcuts(
|
||||
'Edit Broker Tabs',
|
||||
obtenerAtajosEdicionAgente({
|
||||
irGeneral: () => (activeTab = 'general'),
|
||||
irContacto: () => (activeTab = 'contact'),
|
||||
irDireccion: () => (activeTab = 'address'),
|
||||
guardar: handleSave,
|
||||
cancelar: handleCancel
|
||||
})
|
||||
);
|
||||
</script>
|
||||
|
||||
<div class="space-y-3">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="space-y-1">
|
||||
<div class="flex items-center gap-3">
|
||||
<Button variant="ghost" size="icon" onclick={handleCancel}>
|
||||
<ArrowLeft size={20} />
|
||||
</Button>
|
||||
<h1 class="text-3xl font-bold tracking-tight">
|
||||
{isEdit ? `Agente ${formData.broker_key}` : 'Nuevo Agente Aduanal'}
|
||||
</h1>
|
||||
<Badge variant={isEdit ? 'outline' : 'default'}>
|
||||
{isEdit ? 'Edición' : 'Nuevo'}
|
||||
</Badge>
|
||||
</div>
|
||||
<p class="text-muted-foreground ml-12">
|
||||
{isEdit ? 'Modifica la información del agente aduanal' : 'Registra un nuevo agente aduanal en el sistema'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="space-y-1">
|
||||
<div class="flex items-center gap-3">
|
||||
<Button variant="ghost" size="icon" onclick={handleCancel}>
|
||||
<ArrowLeft size={20} />
|
||||
</Button>
|
||||
<h1 class="text-3xl font-bold tracking-tight">
|
||||
{isEdit ? `Agente ${formData.broker_key}` : 'Nuevo Agente Aduanal'}
|
||||
</h1>
|
||||
<Badge variant={isEdit ? 'outline' : 'default'}>
|
||||
{isEdit ? 'Edición' : 'Nuevo'}
|
||||
</Badge>
|
||||
</div>
|
||||
<p class="text-muted-foreground ml-12">
|
||||
{isEdit
|
||||
? 'Modifica la información del agente aduanal'
|
||||
: 'Registra un nuevo agente aduanal en el sistema'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
<Separator />
|
||||
|
||||
<!-- Main Content -->
|
||||
<div class="pb-48">
|
||||
<form onsubmit={(e) => { e.preventDefault(); handleSave(); }}>
|
||||
<Tabs.Root bind:value={activeTab}>
|
||||
|
||||
<!-- Tab: General -->
|
||||
<Tabs.Content value="general">
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title>Información General</Card.Title>
|
||||
<Card.Description>Identificación oficial del agente y patente.</Card.Description>
|
||||
</Card.Header>
|
||||
<Card.Content class="space-y-6">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div class="grid gap-2">
|
||||
<Label class="required">Clave Agente <span class="text-destructive">*</span></Label>
|
||||
<Input bind:value={formData.broker_key} placeholder="Ej. 550" disabled={isEdit || loading} />
|
||||
<p class="text-xs text-muted-foreground">Clave interna o número de patente único.</p>
|
||||
</div>
|
||||
<div class="grid gap-2">
|
||||
<Label class="required">Patente / Autorización <span class="text-destructive">*</span></Label>
|
||||
<Input bind:value={formData.license} placeholder="Ej. 3421" disabled={loading} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<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>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
</Tabs.Content>
|
||||
<!-- Main Content -->
|
||||
<div class="pb-48">
|
||||
<form
|
||||
onsubmit={(e) => {
|
||||
e.preventDefault();
|
||||
handleSave();
|
||||
}}
|
||||
>
|
||||
<Tabs.Root bind:value={activeTab}>
|
||||
<!-- Tab: General -->
|
||||
<Tabs.Content value="general">
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title>Información General</Card.Title>
|
||||
<Card.Description>Identificación oficial del agente y patente.</Card.Description>
|
||||
</Card.Header>
|
||||
<Card.Content class="space-y-6">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div class="grid gap-2">
|
||||
<Label class="required"
|
||||
>Clave Agente <span class="text-destructive">*</span></Label
|
||||
>
|
||||
<Input
|
||||
bind:value={formData.broker_key}
|
||||
placeholder="Ej. 550"
|
||||
disabled={isEdit || loading}
|
||||
/>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
Clave interna o número de patente único.
|
||||
</p>
|
||||
</div>
|
||||
<div class="grid gap-2">
|
||||
<Label class="required"
|
||||
>Patente / Autorización <span class="text-destructive">*</span></Label
|
||||
>
|
||||
<Input bind:value={formData.license} placeholder="Ej. 3421" disabled={loading} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab: Contact -->
|
||||
<Tabs.Content value="contact">
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title>Información de Contacto</Card.Title>
|
||||
<Card.Description>Datos para comunicación con el agente.</Card.Description>
|
||||
</Card.Header>
|
||||
<Card.Content class="space-y-6">
|
||||
<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>
|
||||
|
||||
<Separator />
|
||||
<Separator />
|
||||
|
||||
<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>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
</Tabs.Content>
|
||||
<div class="grid gap-2">
|
||||
<Label>Nombre / Razón Social</Label>
|
||||
<Input bind:value={formData.name} placeholder="Nombre oficial" disabled={loading} />
|
||||
</div>
|
||||
|
||||
<!-- Tab: Address -->
|
||||
<Tabs.Content value="address">
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title>Domicilio Fiscal</Card.Title>
|
||||
<Card.Description>Ubicación registrada del agente aduanal.</Card.Description>
|
||||
</Card.Header>
|
||||
<Card.Content class="space-y-6">
|
||||
<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>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
</Tabs.Content>
|
||||
<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>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
</Tabs.Content>
|
||||
|
||||
</Tabs.Root>
|
||||
</form>
|
||||
</div>
|
||||
<!-- Tab: Contact -->
|
||||
<Tabs.Content value="contact">
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title>Información de Contacto</Card.Title>
|
||||
<Card.Description>Datos para comunicación con el agente.</Card.Description>
|
||||
</Card.Header>
|
||||
<Card.Content class="space-y-6">
|
||||
<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>
|
||||
|
||||
<Separator />
|
||||
|
||||
<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>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
</Tabs.Content>
|
||||
|
||||
<!-- Tab: Address -->
|
||||
<Tabs.Content value="address">
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title>Domicilio Fiscal</Card.Title>
|
||||
<Card.Description>Ubicación registrada del agente aduanal.</Card.Description>
|
||||
</Card.Header>
|
||||
<Card.Content class="space-y-6">
|
||||
<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>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
</Tabs.Content>
|
||||
</Tabs.Root>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sticky Footer -->
|
||||
<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))]">
|
||||
<div class="px-4 py-4 space-y-4 max-w-[1400px] mx-auto">
|
||||
<!-- Footer Navigation -->
|
||||
<Tabs.Root bind:value={activeTab}>
|
||||
<div class="w-full overflow-x-auto pb-2">
|
||||
<Tabs.List class="inline-flex md:grid md:w-full md:grid-cols-3">
|
||||
<Tabs.Trigger value="general" class="whitespace-nowrap">
|
||||
<User size={16} class="mr-2" /> General
|
||||
</Tabs.Trigger>
|
||||
<Tabs.Trigger value="contact" class="whitespace-nowrap">
|
||||
<Phone size={16} class="mr-2" /> Contacto
|
||||
</Tabs.Trigger>
|
||||
<Tabs.Trigger value="address" class="whitespace-nowrap">
|
||||
<MapPin size={16} class="mr-2" /> Dirección
|
||||
</Tabs.Trigger>
|
||||
</Tabs.List>
|
||||
</div>
|
||||
</Tabs.Root>
|
||||
<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))]"
|
||||
>
|
||||
<div class="px-4 py-4 space-y-4 max-w-[1400px] mx-auto">
|
||||
<!-- Footer Navigation -->
|
||||
<Tabs.Root bind:value={activeTab}>
|
||||
<div class="w-full overflow-x-auto pb-2">
|
||||
<Tabs.List class="inline-flex md:grid md:w-full md:grid-cols-3">
|
||||
<Tabs.Trigger value="general" class="whitespace-nowrap">
|
||||
<User size={16} class="mr-2" /> General
|
||||
</Tabs.Trigger>
|
||||
<Tabs.Trigger value="contact" class="whitespace-nowrap">
|
||||
<Phone size={16} class="mr-2" /> Contacto
|
||||
</Tabs.Trigger>
|
||||
<Tabs.Trigger value="address" class="whitespace-nowrap">
|
||||
<MapPin size={16} class="mr-2" /> Dirección
|
||||
</Tabs.Trigger>
|
||||
</Tabs.List>
|
||||
</div>
|
||||
</Tabs.Root>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex justify-end gap-3">
|
||||
<Button variant="outline" onclick={handleCancel} disabled={loading}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button onclick={handleSave} disabled={loading}>
|
||||
{#if loading}
|
||||
<Loader2 class="mr-2 h-4 w-4 animate-spin" />
|
||||
Guardando...
|
||||
{:else}
|
||||
<Save size={16} class="mr-2" />
|
||||
{isEdit ? 'Actualizar Agente' : 'Guardar Agente'}
|
||||
{/if}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Actions -->
|
||||
<div class="flex justify-end gap-3">
|
||||
<Button variant="outline" onclick={handleCancel} disabled={loading}>Cancelar</Button>
|
||||
<Button onclick={handleSave} disabled={loading}>
|
||||
{#if loading}
|
||||
<Loader2 class="mr-2 h-4 w-4 animate-spin" />
|
||||
Guardando...
|
||||
{:else}
|
||||
<Save size={16} class="mr-2" />
|
||||
{isEdit ? 'Actualizar Agente' : 'Guardar Agente'}
|
||||
{/if}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user