feat: Implement Ventanilla Única (VU) management for customs brokers, including API, UI, and data model updates, and add a new state selection dialog.

This commit is contained in:
Galindo97
2026-02-24 14:24:50 -06:00
parent f61bc4a42e
commit a52602dedf
9 changed files with 871 additions and 78 deletions

View File

@@ -94,9 +94,8 @@ depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
"""Upgrade schema."""
# --- UTILIDAD DE FORMATEO ---
def format_value(val):
if val is None or str(val).strip() == "" or str(val).upper() == "NONE":
return "NULL"

View File

@@ -43,6 +43,7 @@ class CustomsBrokerResponseDTO(CustomsBrokerBaseDTO):
broker_key: str
tenant_id: int
company_id: int
vu: Optional["CustomsBrokerVUCreateDTO"] = None
class Config:
from_attributes = True
@@ -75,25 +76,25 @@ class CustomsBrokerDTO(BaseModel):
class CustomsBrokerVUCreateDTO(BaseModel):
certificate_path: Optional[str]
key_path: Optional[str]
access_key: Optional[str]
fiel_format: Optional[str]
signature_read_path: Optional[str]
archive_path: Optional[str]
fiel_access_key: Optional[str]
web_service_user: Optional[str]
web_service_access_key: Optional[str]
vu_email: Optional[str]
vu_figure_type: Optional[str]
xml_files_path: Optional[str]
query_tax_id: Optional[str]
doda_certificate_path: Optional[str]
doda_key_path: Optional[str]
doda_web_service_user: Optional[str]
doda_web_service_access_key: Optional[str]
doda_fiel_access_key: Optional[str]
doda_xml_files_path: Optional[str]
certificate_path: Optional[str] = None
key_path: Optional[str] = None
access_key: Optional[str] = None
fiel_format: Optional[str] = None
signature_read_path: Optional[str] = None
archive_path: Optional[str] = None
fiel_access_key: Optional[str] = None
web_service_user: Optional[str] = None
web_service_access_key: Optional[str] = None
vu_email: Optional[str] = None
vu_figure_type: Optional[str] = None
xml_files_path: Optional[str] = None
query_tax_id: Optional[str] = None
doda_certificate_path: Optional[str] = None
doda_key_path: Optional[str] = None
doda_web_service_user: Optional[str] = None
doda_web_service_access_key: Optional[str] = None
doda_fiel_access_key: Optional[str] = None
doda_xml_files_path: Optional[str] = None
class Config:
from_attributes = True
@@ -102,15 +103,15 @@ class CustomsBrokerVUCreateDTO(BaseModel):
class CustomsBrokerPersonnelDTO(BaseModel):
broker_key: str = Field(..., max_length=5, pattern=r"^[a-zA-Z0-9]+$")
line: int
name: Optional[str]
tax_id: Optional[str]
personal_id: Optional[str]
position: Optional[str]
name: Optional[str] = None
tax_id: Optional[str] = None
personal_id: Optional[str] = None
position: Optional[str] = None
license: Optional[str] = Field(None, max_length=4, pattern=r"^\d*$")
first_name: Optional[str]
last_name: Optional[str]
middle_name: Optional[str]
email: Optional[str]
first_name: Optional[str] = None
last_name: Optional[str] = None
middle_name: Optional[str] = None
email: Optional[str] = None
class Config:
from_attributes = True

View File

@@ -32,7 +32,7 @@ class CustomsBroker(Base, TenantScopedMixin, TimestampMixin):
contact = Column(String(80), nullable=True)
vu = relationship(
"CustomsBrokerVU", back_populates="customs_broker", cascade="all, delete"
"CustomsBrokerVU", back_populates="customs_broker", cascade="all, delete", uselist=False
)
personnel = relationship(
"CustomsBrokerPersonnel", back_populates="customs_broker", cascade="all, delete"

View File

@@ -76,7 +76,8 @@ class CustomsBrokerVUService:
def get_by_broker_key(db: Session, broker_key: str):
return (
db.query(models.CustomsBrokerVU)
.filter(models.CustomsBrokerVU.broker_key == broker_key)
.join(models.CustomsBroker)
.filter(models.CustomsBroker.broker_key == broker_key)
.first()
)
@@ -90,13 +91,28 @@ class CustomsBrokerVUService:
@staticmethod
def update_vu(db: Session, broker_key: str, vu_data: dto.CustomsBrokerVUCreateDTO):
vu = CustomsBrokerVUService.get_by_broker_key(db, broker_key)
# We need the custom broker ID to insert a new VU
broker = db.query(models.CustomsBroker).filter(models.CustomsBroker.broker_key == broker_key).first()
if not broker:
return None
vu = db.query(models.CustomsBrokerVU).filter(models.CustomsBrokerVU.customs_broker_id == broker.id).first()
if vu:
for key, value in vu_data.dict(exclude_unset=True).items():
# Update existing
for key, value in vu_data.model_dump(exclude_unset=True).items():
setattr(vu, key, value)
db.commit()
db.refresh(vu)
return vu
return vu
else:
# Create new
new_vu_data = vu_data.model_dump()
new_vu = models.CustomsBrokerVU(customs_broker_id=broker.id, **new_vu_data)
db.add(new_vu)
db.commit()
db.refresh(new_vu)
return new_vu
@staticmethod
def delete_vu(db: Session, broker_key: str):
@@ -112,16 +128,22 @@ class CustomsBrokerPersonnelService:
def get_by_broker_key_and_line(db: Session, broker_key: str, line: int):
return (
db.query(models.CustomsBrokerPersonnel)
.join(models.CustomsBroker)
.filter(
models.CustomsBrokerPersonnel.broker_key == broker_key,
models.CustomsBroker.broker_key == broker_key,
models.CustomsBrokerPersonnel.line == line,
)
.first()
)
@staticmethod
def create_personnel(db: Session, personnel_data: dto.CustomsBrokerPersonnelDTO):
new_personnel = models.CustomsBrokerPersonnel(**personnel_data.dict())
def create_personnel(db: Session, broker_key: str, personnel_data: dto.CustomsBrokerPersonnelDTO):
broker = db.query(models.CustomsBroker).filter(models.CustomsBroker.broker_key == broker_key).first()
if not broker:
return None
new_personnel_data = personnel_data.model_dump()
new_personnel = models.CustomsBrokerPersonnel(customs_broker_id=broker.id, **new_personnel_data)
db.add(new_personnel)
db.commit()
db.refresh(new_personnel)
@@ -138,7 +160,7 @@ class CustomsBrokerPersonnelService:
db, broker_key, line
)
if personnel:
for key, value in personnel_data.dict(exclude_unset=True).items():
for key, value in personnel_data.model_dump(exclude_unset=True).items():
setattr(personnel, key, value)
db.commit()
db.refresh(personnel)

View File

@@ -22,6 +22,7 @@ export interface CustomsBroker {
contact?: string | null;
tenant_id: string;
company_id: string;
vu?: CustomsBrokerVU | null;
}
export interface CustomsBrokerVU {

View File

@@ -28,8 +28,12 @@ export function createColumns(onSuccess?: () => void): ColumnDef<CustomsBroker>[
cell: ({ row }) => {
const typeSnippet = createRawSnippet<[{ type: string | null | undefined }]>((getType) => {
const { type } = getType();
let display = type || '-';
if (type === 'MEX') display = 'Agente Aduanal Mexicano';
else if (type === 'USA') display = 'Agente Aduanal Americano (Broker)';
return {
render: () => `<div class="max-w-[120px] truncate">${type || '-'}</div>`
render: () => `<div class="max-w-[200px] truncate text-sm">${display}</div>`
};
});
return renderSnippet(typeSnippet, { type: row.original.type });
@@ -68,8 +72,8 @@ export function createColumns(onSuccess?: () => void): ColumnDef<CustomsBroker>[
const postalSnippet = createRawSnippet<[{ postal: string | null | undefined }]>((getPostal) => {
const { postal } = getPostal();
return {
render: () =>
postal
render: () =>
postal
? `<code class="bg-muted px-1 py-0.5 rounded text-sm">${postal}</code>`
: `<span class="text-muted-foreground">-</span>`
};
@@ -136,8 +140,8 @@ export function createColumns(onSuccess?: () => void): ColumnDef<CustomsBroker>[
const licenseSnippet = createRawSnippet<[{ license: string | null | undefined }]>((getLicense) => {
const { license } = getLicense();
return {
render: () =>
license
render: () =>
license
? `<code class="relative rounded bg-blue-100 dark:bg-blue-900 px-[0.3rem] py-[0.2rem] font-mono text-xs font-semibold">${license}</code>`
: `<span class="text-muted-foreground">-</span>`
};
@@ -175,9 +179,9 @@ export function createColumns(onSuccess?: () => void): ColumnDef<CustomsBroker>[
id: "actions",
header: "Acciones",
cell: ({ row }) => {
return renderComponent(DataTableActions, {
return renderComponent(DataTableActions, {
broker: row.original,
onSuccess
onSuccess
});
}
}

View File

@@ -0,0 +1,153 @@
<script lang="ts">
import * as Dialog from '$lib/components/ui/dialog';
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import { Loader2, Search } from 'lucide-svelte';
import { statesApi, type State } from '$lib/api/dashboard/reference_data/states';
let {
open = $bindable(),
onSelect,
countryCode = ''
}: {
open: boolean;
onSelect: (state: State) => void;
countryCode?: string;
} = $props();
let items: State[] = $state([]);
let filteredItems: State[] = $state([]);
let loading = $state(false);
let searchTerm = $state('');
let error = $state('');
async function loadStates() {
loading = true;
error = '';
try {
const response = await statesApi.list(1, 100);
if (response.data?.items) {
items = response.data.items;
filteredItems = items;
} else if (response.error) {
error = response.error;
}
} catch (err) {
error = 'Error loading states';
console.error('Error loading states:', err);
} finally {
loading = false;
}
}
function filterStates() {
let list = items;
// Filter by country if provided
if (countryCode) {
list = list.filter((item) => item.m3_key === countryCode);
}
// Filter by search term
if (searchTerm.trim()) {
const term = searchTerm.toLowerCase();
list = list.filter(
(item) =>
item.m3_key?.toLowerCase().includes(term) ||
item.mex_key?.toLowerCase().includes(term) ||
item.description?.toLowerCase().includes(term)
);
}
filteredItems = list;
}
function handleSelect(item: State) {
onSelect(item);
open = false;
}
$effect(() => {
if (open) {
loadStates();
}
});
$effect(() => {
filterStates();
});
</script>
<Dialog.Root bind:open>
<Dialog.Content class="flex max-h-[90vh] w-[70vw] !max-w-[70vw] flex-col p-0">
<Dialog.Header class="border-b px-6 py-4">
<Dialog.Title class="text-lg font-semibold">CATALOGO DE ESTADOS</Dialog.Title>
</Dialog.Header>
<div class="border-b bg-zinc-50 px-6 py-3 dark:bg-zinc-900">
<div class="flex items-center gap-2">
<Search class="h-4 w-4 text-zinc-400" />
<Input
bind:value={searchTerm}
placeholder="Buscando por clave o nombre..."
class="h-9 flex-1"
/>
</div>
</div>
<div class="flex-1 overflow-auto px-6 py-4">
{#if loading}
<div class="flex items-center justify-center py-20">
<Loader2 class="h-8 w-8 animate-spin text-zinc-900 dark:text-zinc-100" />
</div>
{:else if error}
<div class="flex items-center justify-center py-20 text-red-600">
<p>{error}</p>
</div>
{:else}
<div class="overflow-hidden rounded-md border">
<table class="w-full text-sm">
<thead class="bg-zinc-900 text-white dark:bg-zinc-800">
<tr>
<th class="w-24 border-r border-zinc-700 px-3 py-2 text-left font-semibold"
>Clave M3</th
>
<th class="w-24 border-r border-zinc-700 px-3 py-2 text-left font-semibold"
>Clave Mex</th
>
<th class="w-24 border-r border-zinc-700 px-3 py-2 text-left font-semibold"
>Clave Ame</th
>
<th class="px-3 py-2 text-left font-semibold">Descripción</th>
</tr>
</thead>
<tbody>
{#each filteredItems as item}
<tr
class="cursor-pointer border-b transition-colors hover:bg-zinc-100 dark:hover:bg-zinc-800"
onclick={() => handleSelect(item)}
>
<td class="border-r px-3 py-2 font-mono font-semibold">{item.m3_key || ''}</td>
<td class="border-r px-3 py-2">{item.mex_key || ''}</td>
<td class="border-r px-3 py-2">{item.ame_key || ''}</td>
<td class="px-3 py-2">{item.description || ''}</td>
</tr>
{/each}
{#if filteredItems.length === 0}
<tr>
<td colspan="4" class="px-3 py-8 text-center text-zinc-500">
No se encontraron resultados
</td>
</tr>
{/if}
</tbody>
</table>
</div>
{/if}
</div>
<div class="flex items-center justify-end gap-2 border-t bg-zinc-50 px-6 py-3 dark:bg-zinc-900">
<Button variant="outline" size="sm" onclick={() => (open = false)}>Cancelar</Button>
</div>
</Dialog.Content>
</Dialog.Root>

View File

@@ -4,6 +4,7 @@ export const obtenerAtajosEdicionAgente = (acciones: {
irGeneral: () => void;
irContacto: () => void;
irDireccion: () => void;
irVU: () => void;
guardar: () => void;
cancelar: () => void;
}): ShortcutDef[] => [
@@ -22,6 +23,11 @@ export const obtenerAtajosEdicionAgente = (acciones: {
description: 'Tab Dirección',
action: acciones.irDireccion
},
{
key: 'Alt+Digit4',
description: 'Tab Ventanilla Única',
action: acciones.irVU
},
{
key: 'Ctrl+S',
description: 'Guardar',

View File

@@ -8,6 +8,8 @@
} from '$lib/api/dashboard/a76/customs-brokers';
// UI Components
import CountryDialog from '$lib/components/dashboard/invoices/edit/items/fa/country-dialog.svelte';
import StateDialog from '$lib/components/dashboard/invoices/edit/items/fa/state-dialog.svelte';
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
@@ -15,6 +17,7 @@
import { Badge } from '$lib/components/ui/badge';
import * as Tabs from '$lib/components/ui/tabs';
import * as Card from '$lib/components/ui/card';
import * as Select from '$lib/components/ui/select';
import {
ArrowLeft,
Loader2,
@@ -24,7 +27,18 @@
MapPin,
Settings,
FileText,
Hash
Hash,
FileKey,
Key,
Globe,
Folder,
Mail,
UserRound,
Fingerprint,
Lock,
Signature,
Archive,
ShieldCheck
} from 'lucide-svelte';
import { toast } from 'svelte-sonner';
import { useShortcuts } from '$lib/hooks/use-shortcuts';
@@ -40,6 +54,8 @@
let activeTab = $state('general');
let error = $state<string | null>(null);
let dataLoaded = $state(false);
let showCountryDialog = $state(false);
let showStateDialog = $state(false);
let formData = $state<CreateCustomsBrokerData>({
broker_key: '',
@@ -62,6 +78,28 @@
company_id: ''
});
let vuData = $state({
certificate_path: '',
key_path: '',
xml_files_path: '',
fiel_access_key: '',
doda_web_service_user: '',
doda_web_service_access_key: '',
doda_certificate_path: '',
doda_key_path: '',
doda_fiel_access_key: '',
doda_xml_files_path: '',
web_service_user: '',
web_service_access_key: '',
query_tax_id: '',
vu_email: '',
vu_figure_type: '',
fiel_format: '',
access_key: '',
signature_read_path: '',
archive_path: ''
});
let brokerKeyError = $state(false);
let licenseError = $state(false);
let brokerKeyTimeout: ReturnType<typeof setTimeout>;
@@ -77,6 +115,16 @@
}
});
// --- 5. FUNCIONES ---
function handleLocalFileSelect(event: Event, targetKey: keyof typeof vuData) {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
if (file) {
vuData[targetKey] = file.name;
toast.success(`Archivo ${file.name} seleccionado`);
}
}
async function loadBrokerData(key: string, cId: string) {
if (!key || key === 'undefined') return;
loading = true;
@@ -106,9 +154,32 @@
company: d.company || '',
company_id: cId
};
if (d.vu) {
vuData = {
certificate_path: d.vu.certificate_path || '',
key_path: d.vu.key_path || '',
xml_files_path: d.vu.xml_files_path || '',
fiel_access_key: d.vu.fiel_access_key || '',
doda_web_service_user: d.vu.doda_web_service_user || '',
doda_web_service_access_key: d.vu.doda_web_service_access_key || '',
doda_certificate_path: d.vu.doda_certificate_path || '',
doda_key_path: d.vu.doda_key_path || '',
doda_fiel_access_key: d.vu.doda_fiel_access_key || '',
doda_xml_files_path: d.vu.doda_xml_files_path || '',
web_service_user: d.vu.web_service_user || '',
web_service_access_key: d.vu.web_service_access_key || '',
query_tax_id: d.vu.query_tax_id || '',
vu_email: d.vu.vu_email || '',
vu_figure_type: d.vu.vu_figure_type || '',
fiel_format: d.vu.fiel_format || '',
access_key: d.vu.access_key || '',
signature_read_path: d.vu.signature_read_path || '',
archive_path: d.vu.archive_path || ''
};
}
dataLoaded = true;
} else if (d.error) {
error = d.error;
error = d.error as string;
toast.error(error);
}
} catch (e: any) {
@@ -119,6 +190,18 @@
}
}
function handleCountrySelect(country: any) {
const nextCountry = country.m3_key || country.mex_key || country.ame_key;
if (formData.country !== nextCountry) {
formData.state = '';
}
formData.country = nextCountry;
}
function handleStateSelect(state: any) {
formData.state = state.m3_key || state.mex_key || state.ame_key;
}
// --- 4. GUARDADO ---
async function handleSave() {
if (!companyStore.activeCompany) {
@@ -151,15 +234,31 @@
formData.company_id = cId;
const res = isEdit
? await customsBrokersApi.update(routeId!, formData, cId)
? await customsBrokersApi.update(routeId!, formData)
: await customsBrokersApi.create(formData, cId);
if ((res as any).error) throw new Error((res as any).error);
// UPSERT VU
try {
const vuRes = await customsBrokersApi.updateVU(formData.broker_key, vuData, cId);
if ((vuRes as any).error) {
toast.error(
'Agente guardado, pero ocurrió un error guardando Ventanilla Única: ' +
(vuRes as any).error
);
return; // Avoid triggering success redirection
}
} catch (vuErr: any) {
toast.error('Agente guardado, pero ocurrió un error guardando Ventanilla Única.');
console.error(vuErr);
return;
}
toast.success(isEdit ? 'Agente actualizado' : 'Agente creado');
goto('/dashboard/customs_brokers');
} catch (e: any) {
error = e.message || 'Error al procesar la solicitud';
error = (e.message || 'Error al procesar la solicitud') as string;
toast.error(error);
} finally {
loading = false;
@@ -176,6 +275,7 @@
irGeneral: () => (activeTab = 'general'),
irContacto: () => (activeTab = 'contact'),
irDireccion: () => (activeTab = 'address'),
irVU: () => (activeTab = 'vu'),
guardar: handleSave,
cancelar: handleCancel
})
@@ -197,7 +297,7 @@
{isEdit ? 'Edición' : 'Nuevo'}
</Badge>
</div>
<p class="text-muted-foreground ml-12">
<p class="ml-12 text-muted-foreground">
{isEdit
? 'Modifica la información del agente aduanal'
: 'Registra un nuevo agente aduanal en el sistema'}
@@ -224,7 +324,31 @@
<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 grid-cols-1 gap-6 md:grid-cols-2">
<div class="grid gap-2">
<Label>Tipo de Agente Aduanal</Label>
<Select.Root
type="single"
value={formData.type || undefined}
onValueChange={(v) => (formData.type = v)}
disabled={loading}
>
<Select.Trigger class="h-10 w-full">
{formData.type === 'MEX'
? 'Agente Aduanal Mexicano'
: formData.type === 'USA'
? 'Agente Aduanal Americano (Broker)'
: 'Selecciona un tipo...'}
</Select.Trigger>
<Select.Content>
<Select.Item value="MEX">Agente Aduanal Mexicano</Select.Item>
<Select.Item value="USA">Agente Aduanal Americano (Broker)</Select.Item>
</Select.Content>
</Select.Root>
</div>
</div>
<div class="grid grid-cols-1 gap-6 md:grid-cols-2">
<div class="grid gap-2">
<Label class="required"
>Clave Agente <span class="text-destructive">*</span></Label
@@ -248,8 +372,8 @@
}
}}
placeholder="Ej. 550"
maxlength="6"
class={brokerKeyError ? 'border-red-500 focus-visible:ring-red-500' : ''}
maxlength={6}
class={`h-10 ${brokerKeyError ? 'border-red-500 focus-visible:ring-red-500' : ''}`}
disabled={isEdit || loading}
/>
{#if brokerKeyError}
@@ -281,8 +405,8 @@
}
}}
placeholder="Ej. 3421"
maxlength="5"
class={licenseError ? 'border-red-500 focus-visible:ring-red-500' : ''}
maxlength={5}
class={`h-10 ${licenseError ? 'border-red-500 focus-visible:ring-red-500' : ''}`}
disabled={loading}
/>
{#if licenseError}
@@ -297,16 +421,22 @@
<div class="grid gap-2">
<Label>Nombre / Razón Social</Label>
<Input bind:value={formData.name} placeholder="Nombre oficial" disabled={loading} />
<Input
bind:value={formData.name}
placeholder="Nombre oficial"
disabled={loading}
class="h-10"
/>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div class="grid grid-cols-1 gap-6 md:grid-cols-2">
<div class="grid gap-2">
<Label>RFC</Label>
<Input
bind:value={formData.tax_id}
placeholder="RFC de la empresa"
disabled={loading}
class="h-10"
/>
</div>
<div class="grid gap-2">
@@ -315,6 +445,7 @@
bind:value={formData.personal_id}
placeholder="CURP si aplica"
disabled={loading}
class="h-10"
/>
</div>
</div>
@@ -330,13 +461,14 @@
<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 grid-cols-1 gap-6 md:grid-cols-2">
<div class="grid gap-2">
<Label>Persona de Contacto</Label>
<Input
bind:value={formData.contact}
placeholder="Nombre del contacto"
disabled={loading}
class="h-10"
/>
</div>
<div class="grid gap-2">
@@ -345,24 +477,26 @@
bind:value={formData.position}
placeholder="Ej. Gerente Comercial"
disabled={loading}
class="h-10"
/>
</div>
</div>
<Separator />
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
<div class="grid grid-cols-1 gap-6 md:grid-cols-3">
<div class="grid gap-2">
<Label>Teléfono</Label>
<Input
bind:value={formData.phone}
placeholder="656-000-0000"
disabled={loading}
class="h-10"
/>
</div>
<div class="grid gap-2">
<Label>Fax</Label>
<Input bind:value={formData.fax} disabled={loading} />
<Input bind:value={formData.fax} disabled={loading} class="h-10" />
</div>
<div class="grid gap-2">
<Label>Correo Electrónico</Label>
@@ -371,6 +505,7 @@
bind:value={formData.email}
placeholder="correo@empresa.com"
disabled={loading}
class="h-10"
/>
</div>
</div>
@@ -392,26 +527,501 @@
bind:value={formData.address}
placeholder="Dirección completa"
disabled={loading}
class="h-10"
/>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
<div class="grid grid-cols-1 gap-6 md:grid-cols-3">
<div class="grid gap-2">
<Label>C.P.</Label>
<Input bind:value={formData.postal_code} placeholder="32000" disabled={loading} />
<Input
bind:value={formData.postal_code}
placeholder="32000"
disabled={loading}
class="h-10"
/>
</div>
<div class="grid gap-2 md:col-span-2">
<Label>Ciudad</Label>
<Input bind:value={formData.city} placeholder="Ciudad" disabled={loading} />
<Input
bind:value={formData.city}
placeholder="Ciudad"
disabled={loading}
class="h-10"
/>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div class="grid grid-cols-1 gap-6 md:grid-cols-2">
<div class="grid gap-2">
<Label>Estado</Label>
<Input bind:value={formData.state} placeholder="Estado" disabled={loading} />
<div class="flex gap-1">
<Input
value={formData.state}
readonly
placeholder="Estado"
disabled={loading}
class="h-10 flex-1 cursor-pointer bg-muted"
onclick={() => (showStateDialog = true)}
/>
<Button
variant="outline"
size="icon"
class="h-10 w-10 shrink-0"
onclick={() => (showStateDialog = true)}
disabled={loading}
>
<MapPin class="h-4 w-4" />
</Button>
</div>
</div>
<div class="grid gap-2">
<Label>País</Label>
<Input bind:value={formData.country} placeholder="MEX" disabled={loading} />
<div class="flex gap-1">
<Input
value={formData.country}
readonly
placeholder="MEX"
disabled={loading}
class="h-10 flex-1 cursor-pointer bg-muted"
onclick={() => (showCountryDialog = true)}
/>
<Button
variant="outline"
size="icon"
class="h-10 w-10 shrink-0"
onclick={() => (showCountryDialog = true)}
disabled={loading}
>
<Globe class="h-4 w-4" />
</Button>
</div>
</div>
</div>
</Card.Content>
</Card.Root>
<CountryDialog bind:open={showCountryDialog} onSelect={handleCountrySelect} />
<StateDialog
bind:open={showStateDialog}
onSelect={handleStateSelect}
countryCode={formData.country ?? undefined}
/>
</Tabs.Content>
<!-- Tab: Ventanilla Unica -->
<Tabs.Content value="vu">
<Card.Root>
<Card.Header>
<Card.Title>Ventanilla Única / Web Services</Card.Title>
<Card.Description
>Certificados y credenciales para integración con DODA/PITA.</Card.Description
>
</Card.Header>
<Card.Content class="space-y-6">
<div class="grid grid-cols-1 gap-6 md:grid-cols-2">
<div class="grid gap-2">
<Label
>Ruta de archivo .cer <FileKey
size={14}
class="ml-1 inline text-muted-foreground"
/></Label
>
<div class="flex items-center gap-2">
<Input
bind:value={vuData.certificate_path}
placeholder="/ruta/certificados/agente.cer"
disabled={loading}
class="h-10 flex-1"
/>
<input
type="file"
class="hidden"
onchange={(e) => handleLocalFileSelect(e, 'certificate_path')}
id="vu-cert-file"
/>
<Button
type="button"
variant="outline"
size="icon"
class="h-10 w-10 shrink-0"
disabled={loading}
onclick={() => document.getElementById('vu-cert-file')?.click()}
>
<Folder class="h-4 w-4" />
</Button>
</div>
</div>
<div class="grid gap-2">
<Label
>Ruta de archivo .key <FileKey
size={14}
class="ml-1 inline text-muted-foreground"
/></Label
>
<div class="flex items-center gap-2">
<Input
bind:value={vuData.key_path}
placeholder="/ruta/certificados/agente.key"
disabled={loading}
class="h-10 flex-1"
/>
<input
type="file"
class="hidden"
onchange={(e) => handleLocalFileSelect(e, 'key_path')}
id="vu-key-file"
/>
<Button
type="button"
variant="outline"
size="icon"
class="h-10 w-10 shrink-0"
disabled={loading}
onclick={() => document.getElementById('vu-key-file')?.click()}
>
<Folder class="h-4 w-4" />
</Button>
</div>
</div>
</div>
<div class="grid grid-cols-1 gap-6 md:grid-cols-2">
<div class="grid gap-2">
<Label
>Clave acceso (FIEL) <Key
size={14}
class="ml-1 inline text-muted-foreground"
/></Label
>
<Input
type="password"
bind:value={vuData.fiel_access_key}
placeholder="********"
disabled={loading}
class="h-10"
/>
</div>
<div class="grid gap-2">
<Label
>Tipo figura VU <ShieldCheck
size={14}
class="ml-1 inline text-muted-foreground"
/></Label
>
<Select.Root type="single" bind:value={vuData.vu_figure_type} disabled={loading}>
<Select.Trigger class="h-10">
{vuData.vu_figure_type || 'Seleccionar tipo de figura'}
</Select.Trigger>
<Select.Content>
<Select.Item value="AGENTE ADUANAL">AGENTE ADUANAL</Select.Item>
<Select.Item value="APODERADO ADUANAL">APODERADO ADUANAL</Select.Item>
<Select.Item value="MANDATARIO">MANDATARIO</Select.Item>
</Select.Content>
</Select.Root>
</div>
</div>
<div class="grid grid-cols-1 gap-6 md:grid-cols-2">
<div class="grid gap-2">
<Label
>Correo electrónico <Mail
size={14}
class="ml-1 inline text-muted-foreground"
/></Label
>
<Input
type="email"
bind:value={vuData.vu_email}
placeholder="agente@ejemplo.com"
disabled={loading}
class="h-10"
/>
</div>
<div class="grid gap-2">
<Label
>R.F.C de consulta <Hash
size={14}
class="ml-1 inline text-muted-foreground"
/></Label
>
<Input
bind:value={vuData.query_tax_id}
placeholder="RFC123456789"
disabled={loading}
class="h-10"
/>
</div>
</div>
<div class="grid grid-cols-1 gap-6 md:grid-cols-2">
<div class="grid gap-2">
<Label
>Guardar archivos COVE <FileText
size={14}
class="ml-1 inline text-muted-foreground"
/></Label
>
<div class="flex items-center gap-2">
<Input
bind:value={vuData.xml_files_path}
placeholder="/ruta/cove/"
disabled={loading}
class="h-10 flex-1"
/>
<input
type="file"
class="hidden"
onchange={(e) => handleLocalFileSelect(e, 'xml_files_path')}
id="vu-cove-file"
/>
<Button
type="button"
variant="outline"
size="icon"
class="h-10 w-10 shrink-0"
disabled={loading}
onclick={() => document.getElementById('vu-cove-file')?.click()}
>
<Folder class="h-4 w-4" />
</Button>
</div>
</div>
</div>
<Separator />
<div class="grid grid-cols-1 gap-4 rounded-lg border bg-muted/30 p-4">
<h3 class="text-xs font-bold tracking-wider text-muted-foreground uppercase">
Configuración Adicional
</h3>
<div class="grid grid-cols-1 gap-6 md:grid-cols-2">
<div class="grid gap-2">
<Label>Formato FIEL</Label>
<Input
bind:value={vuData.fiel_format}
placeholder="Ej: PKCS#12"
disabled={loading}
class="h-10"
/>
</div>
<div class="grid gap-2">
<Label>Clave Acceso Adicional</Label>
<Input
type="password"
bind:value={vuData.access_key}
placeholder="********"
disabled={loading}
class="h-10"
/>
</div>
<div class="grid gap-2">
<Label>Ruta Lectura Firma</Label>
<Input
bind:value={vuData.signature_read_path}
placeholder="/ruta/lectura/firma"
disabled={loading}
class="h-10"
/>
</div>
<div class="grid gap-2">
<Label>Ruta Archivo Respaldo</Label>
<Input
bind:value={vuData.archive_path}
placeholder="/ruta/respaldo/"
disabled={loading}
class="h-10"
/>
</div>
</div>
</div>
</Card.Content>
</Card.Root>
</Tabs.Content>
<!-- Tab: DODA -->
<Tabs.Content value="doda">
<Card.Root>
<Card.Header>
<Card.Title>DODA-PITA</Card.Title>
<Card.Description>Configuración de servicios DODA / PITA.</Card.Description>
</Card.Header>
<Card.Content class="space-y-6">
<div class="grid grid-cols-1 gap-6 md:grid-cols-2">
<div class="grid gap-2">
<Label>Usuario <User size={14} class="ml-1 inline text-muted-foreground" /></Label
>
<Input
bind:value={vuData.doda_web_service_user}
placeholder="Usuario Web Service"
disabled={loading}
class="h-10"
/>
</div>
<div class="grid gap-2">
<Label
>Clave de acceso <Key
size={14}
class="ml-1 inline text-muted-foreground"
/></Label
>
<Input
type="password"
bind:value={vuData.doda_web_service_access_key}
placeholder="Contraseña WS"
disabled={loading}
class="h-10"
/>
</div>
</div>
<div class="grid grid-cols-1 gap-6 md:grid-cols-2">
<div class="grid gap-2">
<Label
>Ruta archivo .cer <FileKey
size={14}
class="ml-1 inline text-muted-foreground"
/></Label
>
<div class="flex items-center gap-2">
<Input
bind:value={vuData.doda_certificate_path}
placeholder="/ruta/certificados/doda.cer"
disabled={loading}
class="h-10 flex-1"
/>
<input
type="file"
class="hidden"
onchange={(e) => handleLocalFileSelect(e, 'doda_certificate_path')}
id="doda-cert-file"
/>
<Button
type="button"
variant="outline"
size="icon"
class="h-10 w-10 shrink-0"
disabled={loading}
onclick={() => document.getElementById('doda-cert-file')?.click()}
>
<Folder class="h-4 w-4" />
</Button>
</div>
</div>
<div class="grid gap-2">
<Label
>Ruta archivo .key <FileKey
size={14}
class="ml-1 inline text-muted-foreground"
/></Label
>
<div class="flex items-center gap-2">
<Input
bind:value={vuData.doda_key_path}
placeholder="/ruta/certificados/doda.key"
disabled={loading}
class="h-10 flex-1"
/>
<input
type="file"
class="hidden"
onchange={(e) => handleLocalFileSelect(e, 'doda_key_path')}
id="doda-key-file"
/>
<Button
type="button"
variant="outline"
size="icon"
class="h-10 w-10 shrink-0"
disabled={loading}
onclick={() => document.getElementById('doda-key-file')?.click()}
>
<Folder class="h-4 w-4" />
</Button>
</div>
</div>
</div>
<div class="grid grid-cols-1 gap-6 md:grid-cols-2">
<div class="grid gap-2">
<Label
>Clave acceso .key <Key
size={14}
class="ml-1 inline text-muted-foreground"
/></Label
>
<Input
type="password"
bind:value={vuData.doda_fiel_access_key}
placeholder="********"
disabled={loading}
class="h-10"
/>
</div>
<div class="grid gap-2">
<Label
>Guardar archivos DODA-XML, en: <FileText
size={14}
class="ml-1 inline text-muted-foreground"
/></Label
>
<div class="flex items-center gap-2">
<Input
bind:value={vuData.doda_xml_files_path}
placeholder="/ruta/doda-xml/"
disabled={loading}
class="h-10 flex-1"
/>
<input
type="file"
class="hidden"
onchange={(e) => handleLocalFileSelect(e, 'doda_xml_files_path')}
id="doda-xml-file"
/>
<Button
type="button"
variant="outline"
size="icon"
class="h-10 w-10 shrink-0"
disabled={loading}
onclick={() => document.getElementById('doda-xml-file')?.click()}
>
<Folder class="h-4 w-4" />
</Button>
</div>
</div>
</div>
</Card.Content>
</Card.Root>
</Tabs.Content>
<Tabs.Content value="anam">
<Card.Root>
<Card.Header>
<Card.Title>ANAM</Card.Title>
<Card.Description>Configuración de acceso para ANAM.</Card.Description>
</Card.Header>
<Card.Content class="space-y-6">
<div class="grid grid-cols-1 gap-6 md:grid-cols-2">
<div class="grid gap-2">
<Label>Usuario <User size={14} class="ml-1 inline text-muted-foreground" /></Label
>
<Input
bind:value={vuData.web_service_user}
placeholder="Usuario ANAM"
disabled={loading}
class="h-10"
/>
</div>
<div class="grid gap-2">
<Label
>Clave acceso <Lock
size={14}
class="ml-1 inline text-muted-foreground"
/></Label
>
<Input
type="password"
bind:value={vuData.web_service_access_key}
placeholder="********"
disabled={loading}
class="h-10"
/>
</div>
</div>
</Card.Content>
@@ -424,22 +1034,19 @@
<!-- 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))]"
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 space-y-4 max-w-[1400px] mx-auto">
<div class="mx-auto max-w-[1400px] space-y-4 px-4 py-4">
<!-- 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 class="grid w-full grid-cols-6">
<Tabs.Trigger value="general">General</Tabs.Trigger>
<Tabs.Trigger value="contact">Contacto</Tabs.Trigger>
<Tabs.Trigger value="address">Domicilio</Tabs.Trigger>
<Tabs.Trigger value="vu">VU</Tabs.Trigger>
<Tabs.Trigger value="doda">DODA</Tabs.Trigger>
<Tabs.Trigger value="anam">ANAM</Tabs.Trigger>
</Tabs.List>
</div>
</Tabs.Root>