Se mejoro el disenio de agentes y clientes-proveedores
This commit is contained in:
@@ -2,6 +2,7 @@ import { api } from '$lib/api';
|
||||
import type { ApiResponse } from '$lib/api';
|
||||
|
||||
export interface CustomsBroker {
|
||||
id: number;
|
||||
type?: string | null;
|
||||
broker_key: string;
|
||||
name?: string | null;
|
||||
@@ -19,28 +20,8 @@ export interface CustomsBroker {
|
||||
license: string;
|
||||
company?: string | null;
|
||||
contact?: string | null;
|
||||
tenant_id: string;
|
||||
tenant_id?: string;
|
||||
company_id: string;
|
||||
id: number;
|
||||
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 {
|
||||
@@ -112,7 +93,7 @@ export const customsBrokersApi = {
|
||||
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);
|
||||
},
|
||||
@@ -124,20 +105,6 @@ export const customsBrokersApi = {
|
||||
delete: (brokerKey: string, companyId: string) => {
|
||||
return api.delete<void>(`/v1/a76/customs-brokers/${brokerKey}?company_id=${companyId}`);
|
||||
},
|
||||
/**
|
||||
* Elimina un agente aduanal
|
||||
*/
|
||||
delete: (brokerKey: string, companyId: string) => {
|
||||
return api.delete<CustomsBroker>(`/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);
|
||||
|
||||
@@ -33,22 +33,50 @@
|
||||
|
||||
// Cargar datos al abrir
|
||||
$effect(() => {
|
||||
if (open && !loaded) {
|
||||
if (open) {
|
||||
loadCountries();
|
||||
}
|
||||
});
|
||||
|
||||
async function loadCountries() {
|
||||
loading = true;
|
||||
console.log("Cargando países...");
|
||||
try {
|
||||
const response = await countriesApi.list(1, 300);
|
||||
console.log("Respuesta países FULL:", response);
|
||||
|
||||
if (response.data?.items) {
|
||||
// Caso 1: Estructura esperada { data: { items: [...] } }
|
||||
if (response.data?.items && Array.isArray(response.data.items)) {
|
||||
items = response.data.items;
|
||||
loaded = true;
|
||||
} else {
|
||||
console.warn("No se encontraron países:", response);
|
||||
}
|
||||
// Caso 2: El backend devuelve el array directamente en data { data: [...] }
|
||||
else if (Array.isArray(response.data)) {
|
||||
items = response.data;
|
||||
loaded = true;
|
||||
}
|
||||
// Caso 3: La respuesta en sí es el array (poco probable con el wrapper actual pero posible si algo falla antes)
|
||||
else if (Array.isArray(response)) {
|
||||
items = response;
|
||||
loaded = true;
|
||||
}
|
||||
// Caso 4: data es el objeto paginado pero sin la propiedad items correcta o vacía
|
||||
else if (response.data && typeof response.data === 'object') {
|
||||
// Intentar buscar alguna propiedad que sea array
|
||||
const possibleArray = Object.values(response.data).find(val => Array.isArray(val));
|
||||
if (possibleArray) {
|
||||
items = possibleArray as Country[];
|
||||
loaded = true;
|
||||
} else {
|
||||
console.warn("Estructura de datos no reconocida en countriesApi.list:", response.data);
|
||||
}
|
||||
}
|
||||
else {
|
||||
console.warn("No se encontraron países o formato incorrecto:", response);
|
||||
}
|
||||
|
||||
console.log(`Países cargados: ${items.length}`);
|
||||
|
||||
} catch (e) {
|
||||
console.error("Error cargando países:", e);
|
||||
} finally {
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
import {
|
||||
ArrowLeft, LoaderCircle, Save, Package, DollarSign,
|
||||
FileText, Settings, Image as ImageIcon, FolderSearch,
|
||||
UserCheck, CheckCircle2, XCircle, Tag, Layers, Scale, Info, Briefcase, ShieldCheck
|
||||
UserCheck, CheckCircle2, XCircle, Tag, Layers, Scale, Info, Briefcase, ShieldCheck, Globe
|
||||
} from 'lucide-svelte';
|
||||
|
||||
// Stores & APIs
|
||||
@@ -141,7 +141,6 @@
|
||||
alternate_unit_measure: d.inv_data?.alternate_uom || '',
|
||||
part_photo: d.part_photo || '',
|
||||
is_active: d.is_active ?? true,
|
||||
// Cargar datos FA si existen
|
||||
sector: d.fa_data?.sector || '',
|
||||
fraction_type: d.fa_data?.fraction_type || ''
|
||||
};
|
||||
@@ -193,7 +192,7 @@
|
||||
function handleUOMSelect(item: any) { formData.unit_of_measure = item.code; }
|
||||
function handleAltUOMSelect(item: any) { formData.alternate_unit_measure = item.code; }
|
||||
function handleCurrencySelect(currency: any) {
|
||||
formData.currency_type = ''; // Catálogo no usa currency_type
|
||||
formData.currency_type = '';
|
||||
formData.currency_key = currency.currency_type_code;
|
||||
selectedCurrencyName = currency.currency_type_code;
|
||||
}
|
||||
@@ -210,52 +209,35 @@
|
||||
|
||||
loading = true;
|
||||
try {
|
||||
// Construimos el payload.
|
||||
let commonData: any = { ...formData };
|
||||
|
||||
// Si currency_key está vacío o es el default, ponerlo en null
|
||||
if (!commonData.currency_key || commonData.currency_key === 'USD') {
|
||||
commonData.currency_key = null;
|
||||
}
|
||||
|
||||
if (formType === 'fa') {
|
||||
// Crear fa_data con los campos específicos de FA
|
||||
commonData.fa_data = {
|
||||
sector: formData.sector,
|
||||
fraction_type: formData.fraction_type,
|
||||
origin_country: formData.origin_country
|
||||
};
|
||||
// IMPORTANTE: Eliminar estos campos del nivel raíz para evitar duplicados
|
||||
delete commonData.sector;
|
||||
delete commonData.fraction_type;
|
||||
delete commonData.origin_country;
|
||||
}
|
||||
|
||||
// DEBUG: Ver qué se está enviando
|
||||
console.log('Payload que se enviará:', JSON.stringify(commonData, null, 2));
|
||||
console.log('client_id:', formData.client_id);
|
||||
console.log('selectedClientName:', selectedClientName);
|
||||
|
||||
if (isEdit && partId) {
|
||||
await partsApi.update(partId, commonData, activeCompanyId);
|
||||
} else {
|
||||
const result = await partsApi.create({ ...commonData, company_id: activeCompanyId }, activeCompanyId);
|
||||
console.log('Resultado de creación:', result);
|
||||
if (result.error) {
|
||||
console.error('Error del backend:', result.error);
|
||||
error = result.error;
|
||||
return;
|
||||
}
|
||||
if (result.error) { error = result.error; return; }
|
||||
}
|
||||
goto('/dashboard/goods/parts');
|
||||
} catch (e: any) {
|
||||
console.error('Error completo:', e);
|
||||
error = e.message || 'Error al guardar';
|
||||
} finally { loading = false; }
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-3">
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="space-y-1">
|
||||
<div class="flex items-center gap-3">
|
||||
@@ -267,8 +249,8 @@
|
||||
{isEdit ? "Editar" : "Nueva"}
|
||||
</Badge>
|
||||
</div>
|
||||
<p class="text-muted-foreground">
|
||||
{formType === 'fa' ? 'Activo Fijo' : 'Inventario'}
|
||||
<p class="text-muted-foreground ml-11">
|
||||
{formType === 'fa' ? 'Gestión de Activo Fijo' : 'Gestión de Inventario'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -276,7 +258,7 @@
|
||||
<Separator />
|
||||
|
||||
{#if error}
|
||||
<div class="p-4 rounded-md bg-destructive/10 text-destructive border border-destructive/20 text-sm font-medium">
|
||||
<div class="p-4 rounded-md bg-destructive/10 text-destructive border border-destructive/20 text-sm font-medium animate-in slide-in-from-top-2">
|
||||
⚠️ {error}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -285,101 +267,114 @@
|
||||
|
||||
{#if formType === 'fa'}
|
||||
<form onsubmit={(e) => { e.preventDefault(); handleSubmit(); }} class="space-y-6">
|
||||
<Card.Root>
|
||||
<Card.Root class="shadow-sm">
|
||||
<Card.Content class="p-6">
|
||||
<Tabs.Root bind:value={activeTab} class="w-full">
|
||||
|
||||
<div class="min-h-[500px]">
|
||||
|
||||
<Tabs.Content value="general" class="space-y-6 pt-4 animate-in fade-in duration-300">
|
||||
<Tabs.Content value="general" class="space-y-8 pt-6 animate-in fade-in duration-300">
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div class="grid gap-2">
|
||||
<div class="grid grid-cols-1 md:grid-cols-12 gap-6">
|
||||
<div class="md:col-span-6 space-y-2">
|
||||
<Label for="fa_pn" class="text-base font-semibold required">Número de Parte</Label>
|
||||
<Input id="fa_pn" bind:value={formData.part_number} disabled={isEdit} maxlength={50} class="text-lg font-mono" placeholder="Ej: MAQ-001"/>
|
||||
<div class="relative">
|
||||
<Package class="absolute left-3 top-2.5 h-5 w-5 text-muted-foreground" />
|
||||
<Input id="fa_pn" bind:value={formData.part_number} disabled={isEdit} maxlength={50} class="pl-10 text-lg font-mono bg-muted/20" placeholder="Ej: MAQ-001"/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid gap-2">
|
||||
<Label for="fa_client" class="required">Cliente Asignado</Label>
|
||||
<div class="md:col-span-6 space-y-2">
|
||||
<Label for="fa_client" class="text-base font-semibold required">Cliente Asignado</Label>
|
||||
<div class="flex gap-2">
|
||||
<Input id="fa_client" bind:value={selectedClientName} readonly onclick={() => showClientModal = true} class="cursor-pointer" placeholder="Seleccione..."/>
|
||||
<Button variant="outline" size="icon" type="button" onclick={() => showClientModal = true}><FolderSearch class="h-4 w-4" /></Button>
|
||||
<div class="relative w-full">
|
||||
<UserCheck class="absolute left-3 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input id="fa_client" bind:value={selectedClientName} readonly onclick={() => showClientModal = true} class="pl-9 cursor-pointer hover:bg-muted/50 transition-colors" placeholder="Seleccione un cliente..."/>
|
||||
</div>
|
||||
<Button variant="secondary" size="icon" type="button" onclick={() => showClientModal = true} class="shrink-0"><FolderSearch class="h-4 w-4" /></Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div class="grid gap-2">
|
||||
<Label for="fa_desc_es">Descripción Español</Label>
|
||||
<Textarea id="fa_desc_es" bind:value={formData.description_spanish} rows={2}/>
|
||||
<div class="grid grid-cols-1 md:grid-cols-12 gap-6">
|
||||
<div class="md:col-span-6 space-y-2">
|
||||
<Label for="fa_desc_es" class="flex items-center gap-2">Descripción Español <span class="text-xs text-muted-foreground font-normal">(Requerido)</span></Label>
|
||||
<Textarea id="fa_desc_es" bind:value={formData.description_spanish} rows={3} class="resize-none" placeholder="Descripción detallada en español..."/>
|
||||
</div>
|
||||
<div class="grid gap-2">
|
||||
<Label for="fa_desc_en">Descripción Inglés</Label>
|
||||
<Textarea id="fa_desc_en" bind:value={formData.description_english} rows={2}/>
|
||||
<div class="md:col-span-6 space-y-2">
|
||||
<Label for="fa_desc_en" class="flex items-center gap-2">Descripción Inglés</Label>
|
||||
<Textarea id="fa_desc_en" bind:value={formData.description_english} rows={3} class="resize-none" placeholder="English description..."/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 p-4 border rounded-lg bg-slate-50 dark:bg-slate-900/30">
|
||||
<div class="grid gap-2">
|
||||
<div class="grid grid-cols-1 md:grid-cols-12 gap-6">
|
||||
<div class="md:col-span-4 space-y-2">
|
||||
<Label for="fa_class" class="required">Clase</Label>
|
||||
<div class="flex gap-2">
|
||||
<Input id="fa_class" bind:value={formData.part_class} readonly onclick={() => showClassModal = true} class="cursor-pointer" placeholder="Seleccione..."/>
|
||||
<Button variant="outline" size="icon" type="button" onclick={() => showClassModal = true}><FolderSearch class="h-4 w-4" /></Button>
|
||||
<Input id="fa_class" bind:value={formData.part_class} readonly onclick={() => showClassModal = true} class="cursor-pointer font-mono" placeholder="Seleccione..."/>
|
||||
<Button variant="outline" size="icon" type="button" onclick={() => showClassModal = true} class="shrink-0"><FolderSearch class="h-4 w-4" /></Button>
|
||||
</div>
|
||||
{#if selectedClassDesc}<div class="text-xs text-muted-foreground">{selectedClassDesc}</div>{/if}
|
||||
{#if selectedClassDesc}<p class="text-[11px] text-muted-foreground truncate px-1">{selectedClassDesc}</p>{/if}
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<div class="md:col-span-4 space-y-2">
|
||||
<Label for="fa_uom" class="required">Unidad Medida</Label>
|
||||
<div class="flex gap-2">
|
||||
<Input id="fa_uom" bind:value={formData.unit_of_measure} readonly onclick={() => showUOMModal = true} class="cursor-pointer" placeholder="PZ"/>
|
||||
<Button variant="outline" size="icon" type="button" onclick={() => showUOMModal = true}><FolderSearch class="h-4 w-4" /></Button>
|
||||
<div class="relative w-full">
|
||||
<Scale class="absolute left-3 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input id="fa_uom" bind:value={formData.unit_of_measure} readonly onclick={() => showUOMModal = true} class="pl-9 cursor-pointer font-mono" placeholder="PZ"/>
|
||||
</div>
|
||||
<Button variant="outline" size="icon" type="button" onclick={() => showUOMModal = true} class="shrink-0"><FolderSearch class="h-4 w-4" /></Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="fa_origin">País Origen (ISO)</Label>
|
||||
<div class="md:col-span-4 space-y-2">
|
||||
<Label for="fa_origin">País Origen</Label>
|
||||
<div class="flex gap-2">
|
||||
<Input id="fa_origin" bind:value={selectedCountryName} readonly onclick={() => showCountryModal = true} class="cursor-pointer" placeholder="Seleccione..."/>
|
||||
<Button variant="outline" size="icon" type="button" onclick={() => showCountryModal = true}><FolderSearch class="h-4 w-4" /></Button>
|
||||
<div class="relative w-full">
|
||||
<Globe class="absolute left-3 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input id="fa_origin" bind:value={selectedCountryName} readonly onclick={() => showCountryModal = true} class="pl-9 cursor-pointer" placeholder="Seleccione..."/>
|
||||
</div>
|
||||
<Button variant="outline" size="icon" type="button" onclick={() => showCountryModal = true} class="shrink-0"><FolderSearch class="h-4 w-4" /></Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-4 border rounded-lg bg-green-50/50 dark:bg-green-900/10 space-y-4">
|
||||
<h3 class="font-medium text-sm text-green-800 dark:text-green-300 flex items-center gap-2"><DollarSign class="h-4 w-4"/> Costos y Pesos</h3>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div class="grid gap-2">
|
||||
<div class="p-5 border rounded-xl bg-slate-50 dark:bg-slate-900/30 space-y-4">
|
||||
<h3 class="font-semibold text-sm text-primary flex items-center gap-2">
|
||||
<DollarSign class="h-4 w-4"/> Información Financiera y Pesos
|
||||
</h3>
|
||||
<Separator class="bg-primary/10" />
|
||||
<div class="grid grid-cols-1 md:grid-cols-12 gap-6 pt-2">
|
||||
<div class="md:col-span-4 space-y-2">
|
||||
<Label for="fa_cost">Costo Unitario</Label>
|
||||
<div class="relative">
|
||||
<span class="absolute left-3 top-2.5 text-muted-foreground">$</span>
|
||||
<Input type="number" step="0.0001" id="fa_cost" bind:value={formData.unit_cost} class="pl-7" />
|
||||
<span class="absolute left-3 top-2.5 text-muted-foreground font-semibold">$</span>
|
||||
<Input type="number" step="0.0001" id="fa_cost" bind:value={formData.unit_cost} class="pl-7" placeholder="0.0000" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid gap-2">
|
||||
|
||||
<div class="md:col-span-4 space-y-2">
|
||||
<Label>Moneda</Label>
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex items-center gap-4">
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="radio" name="fa_currency_type" value="NA" bind:group={formData.currency_type} class="h-4 w-4" />
|
||||
<span class="text-sm">Nacional</span>
|
||||
<div class="space-y-2">
|
||||
<div class="flex gap-4 mb-2">
|
||||
<label class="text-sm flex items-center gap-2 cursor-pointer hover:text-primary transition-colors">
|
||||
<input type="radio" name="fa_currency_type" value="NA" bind:group={formData.currency_type} class="accent-primary" /> Nacional
|
||||
</label>
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="radio" name="fa_currency_type" value="EX" bind:group={formData.currency_type} class="h-4 w-4" />
|
||||
<span class="text-sm">Extranjera</span>
|
||||
<label class="text-sm flex items-center gap-2 cursor-pointer hover:text-primary transition-colors">
|
||||
<input type="radio" name="fa_currency_type" value="EX" bind:group={formData.currency_type} class="accent-primary" /> Extranjera
|
||||
</label>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" type="button" onclick={() => { formData.currency_type = 'CATALOG'; showCurrencyModal = true; }} class="w-full">
|
||||
<FolderSearch class="h-4 w-4 mr-2" />
|
||||
{selectedCurrencyName || 'Seleccionar del Catálogo'}
|
||||
<Button variant="outline" size="sm" type="button" onclick={() => { formData.currency_type = 'CATALOG'; showCurrencyModal = true; }} class="w-full justify-between font-normal">
|
||||
<span class="flex items-center gap-2"><FolderSearch class="h-3.5 w-3.5" /> {selectedCurrencyName || 'Catálogo'}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid gap-2">
|
||||
|
||||
<div class="md:col-span-4 space-y-2">
|
||||
<Label for="fa_weight">Peso Unitario</Label>
|
||||
<div class="flex gap-2">
|
||||
<Input type="number" step="0.0001" id="fa_weight" bind:value={formData.unit_weight} placeholder="0.0000" />
|
||||
<Select.Root type="single" bind:value={formData.weight_type}>
|
||||
<Select.Trigger class="w-[80px]">{formData.weight_type}</Select.Trigger>
|
||||
<Select.Trigger class="w-[85px]">{formData.weight_type}</Select.Trigger>
|
||||
<Select.Content>
|
||||
<Select.Item value="KG">KG</Select.Item>
|
||||
<Select.Item value="LB">LB</Select.Item>
|
||||
@@ -390,88 +385,102 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4 p-4 border rounded-lg">
|
||||
<h3 class="font-medium text-sm text-muted-foreground flex items-center gap-2"><Briefcase class="h-4 w-4"/> Datos Aduaneros</h3>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div class="grid gap-2">
|
||||
<div class="p-5 border rounded-xl space-y-4">
|
||||
<h3 class="font-semibold text-sm text-muted-foreground flex items-center gap-2">
|
||||
<Briefcase class="h-4 w-4"/> Clasificación Aduanera
|
||||
</h3>
|
||||
<div class="grid grid-cols-1 md:grid-cols-12 gap-6">
|
||||
<div class="md:col-span-6 space-y-2">
|
||||
<Label for="fa_fraction">Fracción UMT (MX)</Label>
|
||||
<div class="flex gap-2">
|
||||
<Input id="fa_fraction" bind:value={formData.fraction} readonly onclick={() => showFractionModal = true} class="cursor-pointer" placeholder="Seleccione..."/>
|
||||
<Button variant="outline" size="icon" type="button" onclick={() => showFractionModal = true}><FolderSearch class="h-4 w-4" /></Button>
|
||||
<Input id="fa_fraction" bind:value={formData.fraction} readonly onclick={() => showFractionModal = true} class="cursor-pointer font-mono" placeholder="Seleccione..."/>
|
||||
<Button variant="outline" size="icon" type="button" onclick={() => showFractionModal = true} class="shrink-0"><FolderSearch class="h-4 w-4" /></Button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid gap-2">
|
||||
<div class="md:col-span-6 space-y-2">
|
||||
<Label for="fa_us_fraction">Fracción Americana</Label>
|
||||
<Input id="fa_us_fraction" bind:value={formData.us_fraction} maxlength={10} class="font-mono" placeholder="HTS Code"/>
|
||||
</div>
|
||||
<div class="md:col-span-6 space-y-2">
|
||||
<Label for="fa_sector">Sector</Label>
|
||||
<Input id="fa_sector" bind:value={formData.sector} placeholder="Ej: Automotriz"/>
|
||||
</div>
|
||||
<div class="grid gap-2">
|
||||
<div class="md:col-span-6 space-y-2">
|
||||
<Label for="fa_fraction_type">Tipo Tarifa</Label>
|
||||
<Input id="fa_fraction_type" bind:value={formData.fraction_type} />
|
||||
</div>
|
||||
<div class="grid gap-2">
|
||||
<Label for="fa_us_fraction">Fracción Americana</Label>
|
||||
<Input id="fa_us_fraction" bind:value={formData.us_fraction} maxlength={10} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3 pt-2">
|
||||
<div class="flex items-center gap-3 pt-2 pl-1">
|
||||
<Switch id="fa_is_active" bind:checked={formData.is_active} />
|
||||
<Label for="fa_is_active">Activo en sistema</Label>
|
||||
<Label for="fa_is_active" class="cursor-pointer">Parte activa en sistema</Label>
|
||||
</div>
|
||||
|
||||
</Tabs.Content>
|
||||
|
||||
<Tabs.Content value="cont1" class="space-y-6 pt-4 animate-in fade-in duration-300">
|
||||
<div class="p-6 border rounded-lg space-y-4">
|
||||
<div class="grid gap-4 max-w-lg mx-auto">
|
||||
<Label for="fa_photo" class="text-left flex items-center gap-2">
|
||||
<ImageIcon class="h-4 w-4"/> URL Imagen de la parte
|
||||
<Tabs.Content value="cont1" class="space-y-6 pt-6 animate-in fade-in duration-300">
|
||||
<div class="p-6 border rounded-xl space-y-4">
|
||||
<div class="max-w-2xl mx-auto space-y-4">
|
||||
<Label for="fa_photo" class="flex items-center gap-2 text-base">
|
||||
<ImageIcon class="h-5 w-5"/> URL Imagen de la parte
|
||||
</Label>
|
||||
<Input id="fa_photo" bind:value={formData.part_photo} maxlength={255} placeholder="https://ejemplo.com/imagen.jpg" />
|
||||
<div class="flex gap-2">
|
||||
<Input id="fa_photo" bind:value={formData.part_photo} maxlength={255} placeholder="https://ejemplo.com/imagen.jpg" />
|
||||
<Button variant="outline" class="shrink-0">Vista Previa</Button>
|
||||
</div>
|
||||
{#if formData.part_photo}
|
||||
<div class="mt-4 aspect-video rounded-lg bg-muted flex items-center justify-center border overflow-hidden">
|
||||
<img src={formData.part_photo} alt="Vista previa" class="h-full w-full object-contain" />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</Tabs.Content>
|
||||
|
||||
<Tabs.Content value="cont2" class="space-y-6 pt-4 animate-in fade-in duration-300">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div class="space-y-4 p-4 border rounded-lg">
|
||||
<Tabs.Content value="cont2" class="space-y-6 pt-6 animate-in fade-in duration-300">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||
<div class="p-5 border rounded-xl space-y-4">
|
||||
<h3 class="font-medium text-sm text-muted-foreground flex items-center gap-2"><ShieldCheck class="h-4 w-4"/> Control de Exportación</h3>
|
||||
<div class="grid gap-2">
|
||||
<Label for="fa_lic">License Code</Label>
|
||||
<Input id="fa_lic" bind:value={formData.license_code} maxlength={3} />
|
||||
</div>
|
||||
<div class="grid gap-2">
|
||||
<Label for="fa_exp">Export Code</Label>
|
||||
<Input id="fa_exp" bind:value={formData.export_code} maxlength={2} />
|
||||
</div>
|
||||
<div class="grid gap-2">
|
||||
<Label for="fa_eccn">ECCN</Label>
|
||||
<Input id="fa_eccn" bind:value={formData.eccn} maxlength={20} />
|
||||
</div>
|
||||
<div class="grid gap-2">
|
||||
<Label for="fa_exc">Símbolo Excepción Licencia</Label>
|
||||
<Input id="fa_exc" bind:value={formData.exclusion_symbol} maxlength={19} />
|
||||
<div class="grid gap-4">
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="fa_lic">License Code</Label>
|
||||
<Input id="fa_lic" bind:value={formData.license_code} maxlength={3} class="font-mono" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="fa_exp">Export Code</Label>
|
||||
<Input id="fa_exp" bind:value={formData.export_code} maxlength={2} class="font-mono" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="fa_eccn">ECCN</Label>
|
||||
<Input id="fa_eccn" bind:value={formData.eccn} maxlength={20} class="font-mono" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="fa_exc">Símbolo Excepción Licencia</Label>
|
||||
<Input id="fa_exc" bind:value={formData.exclusion_symbol} maxlength={19} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4 p-4 border rounded-lg">
|
||||
<div class="p-5 border rounded-xl space-y-4">
|
||||
<h3 class="font-medium text-sm text-muted-foreground flex items-center gap-2"><Info class="h-4 w-4"/> Regulaciones Adicionales</h3>
|
||||
<div class="grid gap-2">
|
||||
<Label for="fa_fda">Clave FDA</Label>
|
||||
<Input id="fa_fda" bind:value={formData.fda_key} maxlength={20} />
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="fa_fcc">Clave FCC</Label>
|
||||
<Input id="fa_fcc" bind:value={formData.fcc_key} maxlength={30} />
|
||||
<div class="space-y-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="fa_fda">Clave FDA</Label>
|
||||
<Input id="fa_fda" bind:value={formData.fda_key} maxlength={20} />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="fa_fcc">Clave FCC</Label>
|
||||
<Input id="fa_fcc" bind:value={formData.fcc_key} maxlength={30} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Tabs.Content>
|
||||
|
||||
</div>
|
||||
|
||||
</Tabs.Root>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
@@ -479,122 +488,109 @@
|
||||
|
||||
{:else}
|
||||
<form onsubmit={(e) => { e.preventDefault(); handleSubmit(); }} class="space-y-6">
|
||||
<Card.Root>
|
||||
<Card.Root class="shadow-sm">
|
||||
<Card.Content class="p-6">
|
||||
<Tabs.Root bind:value={activeTab} class="w-full">
|
||||
|
||||
<div class="min-h-[500px]">
|
||||
|
||||
<Tabs.Content value="general" class="space-y-6 pt-4 animate-in fade-in duration-300">
|
||||
<Tabs.Content value="general" class="space-y-8 pt-6 animate-in fade-in duration-300">
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="part_number" class="text-base font-semibold required">Número de Parte</Label>
|
||||
<Input id="part_number" bind:value={formData.part_number} disabled={isEdit} maxlength={50} class="text-lg font-mono" placeholder="Ej: 123-ABC-456"/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3 p-4 border rounded-lg bg-slate-50 dark:bg-slate-900/30">
|
||||
<h3 class="font-medium text-sm text-muted-foreground flex items-center gap-2">
|
||||
<FileText class="h-4 w-4"/> Descripción
|
||||
</h3>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div class="grid gap-2">
|
||||
<Label for="desc_es">Español</Label>
|
||||
<Textarea id="desc_es" bind:value={formData.description_spanish} maxlength={500} rows={3}/>
|
||||
</div>
|
||||
<div class="grid gap-2">
|
||||
<Label for="desc_en">Inglés</Label>
|
||||
<Textarea id="desc_en" bind:value={formData.description_english} maxlength={500} rows={3}/>
|
||||
<div class="grid grid-cols-1 md:grid-cols-12 gap-6">
|
||||
<div class="md:col-span-4 space-y-2">
|
||||
<Label for="part_number" class="text-base font-semibold required">Número de Parte</Label>
|
||||
<div class="relative">
|
||||
<Package class="absolute left-3 top-2.5 h-5 w-5 text-muted-foreground" />
|
||||
<Input id="part_number" bind:value={formData.part_number} disabled={isEdit} maxlength={50} class="pl-10 text-lg font-mono bg-muted/20" placeholder="Ej: 123-ABC-456"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div class="grid gap-2">
|
||||
<Label for="part_class_client" class="required">Clase (Anexo 24 - Cliente)</Label>
|
||||
<div class="p-5 border rounded-xl bg-slate-50 dark:bg-slate-900/30 space-y-4">
|
||||
<h3 class="font-medium text-sm text-muted-foreground flex items-center gap-2">
|
||||
<FileText class="h-4 w-4"/> Descripción de Mercancía
|
||||
</h3>
|
||||
<div class="grid grid-cols-1 md:grid-cols-12 gap-6">
|
||||
<div class="md:col-span-6 space-y-2">
|
||||
<Label for="desc_es">Español <span class="text-xs text-muted-foreground font-normal ml-1">(Principal)</span></Label>
|
||||
<Textarea id="desc_es" bind:value={formData.description_spanish} maxlength={500} rows={3} class="bg-background"/>
|
||||
</div>
|
||||
<div class="md:col-span-6 space-y-2">
|
||||
<Label for="desc_en">Inglés</Label>
|
||||
<Textarea id="desc_en" bind:value={formData.description_english} maxlength={500} rows={3} class="bg-background"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-12 gap-6">
|
||||
<div class="md:col-span-6 space-y-2">
|
||||
<Label for="part_class_client" class="required">Clase (Anexo 24)</Label>
|
||||
<div class="flex gap-2">
|
||||
<div class="relative flex-1">
|
||||
<div class="absolute left-3 top-2.5 text-muted-foreground">
|
||||
<Package class="h-4 w-4" />
|
||||
</div>
|
||||
<div class="relative w-full">
|
||||
<Tag class="absolute left-3 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input id="part_class_client" bind:value={formData.part_class} maxlength={8} placeholder="Seleccione Clase..." class="pl-9 font-mono cursor-pointer" readonly onclick={() => showClassModal = true}/>
|
||||
</div>
|
||||
<Button variant="outline" size="icon" type="button" onclick={() => showClassModal = true}><FolderSearch class="h-4 w-4" /></Button>
|
||||
<Button variant="outline" size="icon" type="button" onclick={() => showClassModal = true} class="shrink-0"><FolderSearch class="h-4 w-4" /></Button>
|
||||
</div>
|
||||
{#if selectedClassDesc}<div class="text-xs text-primary font-medium px-1 flex items-center gap-1"><Tag class="h-3 w-3" />{selectedClassDesc}</div>{/if}
|
||||
{#if selectedClassDesc}<p class="text-[11px] text-primary font-medium px-1">{selectedClassDesc}</p>{/if}
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="part_class_material">Tipo Material (Catálogo General)</Label>
|
||||
<div class="md:col-span-6 space-y-2">
|
||||
<Label for="part_class_material">Tipo Material <span class="text-xs text-muted-foreground font-normal ml-1">(Catálogo General)</span></Label>
|
||||
<div class="flex gap-2">
|
||||
<div class="relative flex-1">
|
||||
<div class="absolute left-3 top-2.5 text-muted-foreground">
|
||||
<Layers class="h-4 w-4" />
|
||||
</div>
|
||||
<div class="relative w-full">
|
||||
<Layers class="absolute left-3 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input id="part_class_material" bind:value={formData.material_type} maxlength={8} placeholder="Seleccione Material..." class="pl-9 font-mono cursor-pointer" readonly onclick={() => showMaterialModal = true}/>
|
||||
</div>
|
||||
<Button variant="outline" size="icon" type="button" onclick={() => showMaterialModal = true}><FolderSearch class="h-4 w-4" /></Button>
|
||||
<Button variant="outline" size="icon" type="button" onclick={() => showMaterialModal = true} class="shrink-0"><FolderSearch class="h-4 w-4" /></Button>
|
||||
</div>
|
||||
{#if selectedMaterialDesc}<div class="text-xs text-blue-600 font-medium px-1 flex items-center gap-1"><Layers class="h-3 w-3" />{selectedMaterialDesc}</div>{/if}
|
||||
<p class="text-[10px] text-muted-foreground">Opcional: Clasificación adicional por tipo de material.</p>
|
||||
{#if selectedMaterialDesc}<p class="text-[11px] text-blue-600 font-medium px-1">{selectedMaterialDesc}</p>{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<div class="grid grid-cols-1 md:grid-cols-12 gap-6">
|
||||
<div class="md:col-span-6 space-y-2">
|
||||
<Label for="country">País Origen (ISO)</Label>
|
||||
<div class="flex gap-2">
|
||||
<Input id="country" bind:value={selectedCountryName} readonly onclick={() => showCountryModal = true} class="cursor-pointer" placeholder="Seleccione..."/>
|
||||
<Button variant="outline" size="icon" type="button" onclick={() => showCountryModal = true}><FolderSearch class="h-4 w-4" /></Button>
|
||||
<div class="relative w-full">
|
||||
<Globe class="absolute left-3 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input id="country" bind:value={selectedCountryName} readonly onclick={() => showCountryModal = true} class="pl-9 cursor-pointer" placeholder="Seleccione..."/>
|
||||
</div>
|
||||
<Button variant="outline" size="icon" type="button" onclick={() => showCountryModal = true} class="shrink-0"><FolderSearch class="h-4 w-4" /></Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<div class="md:col-span-6 space-y-2">
|
||||
<Label for="uom" class="required">Unidad de Medida (TIGIE)</Label>
|
||||
<div class="flex gap-2">
|
||||
<div class="relative flex-1">
|
||||
<div class="absolute left-3 top-2.5 text-muted-foreground">
|
||||
<Scale class="h-4 w-4" />
|
||||
</div>
|
||||
<div class="relative w-full">
|
||||
<Scale class="absolute left-3 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input id="uom" bind:value={formData.unit_of_measure} placeholder="Seleccione..." class="pl-9 font-mono cursor-pointer" readonly onclick={() => showUOMModal = true}/>
|
||||
</div>
|
||||
<Button variant="outline" size="icon" type="button" onclick={() => showUOMModal = true}><FolderSearch class="h-4 w-4" /></Button>
|
||||
<Button variant="outline" size="icon" type="button" onclick={() => showUOMModal = true} class="shrink-0"><FolderSearch class="h-4 w-4" /></Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-4 border rounded-lg bg-green-50/50 dark:bg-green-900/10 space-y-4">
|
||||
<h3 class="font-medium text-sm text-green-800 dark:text-green-300 flex items-center gap-2"><DollarSign class="h-4 w-4"/> Costos, valores y peso unitario</h3>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div class="grid gap-2">
|
||||
<Label>Tipos de Moneda</Label>
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex items-center gap-4">
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="radio" name="inv_currency_type" value="NA" bind:group={formData.currency_type} class="h-4 w-4" />
|
||||
<span class="text-sm">Nacional</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="radio" name="inv_currency_type" value="EX" bind:group={formData.currency_type} class="h-4 w-4" />
|
||||
<span class="text-sm">Extranjera</span>
|
||||
</label>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" type="button" onclick={() => { formData.currency_type = 'CATALOG'; showCurrencyModal = true; }} class="w-full">
|
||||
<FolderSearch class="h-4 w-4 mr-2" />
|
||||
{selectedCurrencyName || 'Seleccionar del Catálogo'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid gap-2">
|
||||
<div class="p-5 border rounded-xl bg-slate-50 dark:bg-slate-900/30 space-y-6">
|
||||
<h3 class="font-medium text-sm text-green-700 dark:text-green-400 flex items-center gap-2">
|
||||
<DollarSign class="h-4 w-4"/> Costos y Valor Agregado
|
||||
</h3>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-12 gap-6">
|
||||
<div class="md:col-span-4 space-y-2">
|
||||
<Label for="unit_cost">Costo Unitario</Label>
|
||||
<div class="relative">
|
||||
<span class="absolute left-3 top-2.5 text-muted-foreground">$</span>
|
||||
<Input type="number" step="0.0001" id="unit_cost" bind:value={formData.unit_cost} class="pl-7" />
|
||||
<span class="absolute left-3 top-2.5 text-muted-foreground font-semibold">$</span>
|
||||
<Input type="number" step="0.0001" id="unit_cost" bind:value={formData.unit_cost} class="pl-7 bg-background" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid gap-2">
|
||||
|
||||
<div class="md:col-span-4 space-y-2">
|
||||
<Label for="unit_weight">Peso Unitario</Label>
|
||||
<div class="flex gap-2">
|
||||
<Input type="number" step="0.0001" id="unit_weight" bind:value={formData.unit_weight} placeholder="0.0000" />
|
||||
<Input type="number" step="0.0001" id="unit_weight" bind:value={formData.unit_weight} placeholder="0.0000" class="bg-background"/>
|
||||
<Select.Root type="single" bind:value={formData.weight_type}>
|
||||
<Select.Trigger class="w-[100px]">{formData.weight_type}</Select.Trigger>
|
||||
<Select.Trigger class="w-[90px] bg-background">{formData.weight_type}</Select.Trigger>
|
||||
<Select.Content>
|
||||
<Select.Item value="KG">KG</Select.Item>
|
||||
<Select.Item value="LB">LB</Select.Item>
|
||||
@@ -602,135 +598,177 @@
|
||||
</Select.Root>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3 p-4 border rounded-lg">
|
||||
<Label class="font-semibold">Tipo Valor Agregado</Label>
|
||||
<div class="flex flex-wrap gap-6">
|
||||
<div class="flex items-center space-x-2">
|
||||
<input type="radio" id="va_ext" name="va_type" value="USD" bind:group={formData.value_added_type} class="accent-primary h-4 w-4 cursor-pointer" />
|
||||
<Label for="va_ext" class="font-normal cursor-pointer">Extranjera (Dls)</Label>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<input type="radio" id="va_nac" name="va_type" value="MXN" bind:group={formData.value_added_type} class="accent-primary h-4 w-4 cursor-pointer" />
|
||||
<Label for="va_nac" class="font-normal cursor-pointer">Nacional (Pesos)</Label>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<input type="radio" id="va_pct" name="va_type" value="PERCENT" bind:group={formData.value_added_type} class="accent-primary h-4 w-4 cursor-pointer" />
|
||||
<Label for="va_pct" class="font-normal cursor-pointer">Porcentaje</Label>
|
||||
<div class="md:col-span-4 space-y-2">
|
||||
<Label>Moneda</Label>
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex gap-4">
|
||||
<label class="text-xs flex items-center gap-2 cursor-pointer">
|
||||
<input type="radio" name="inv_currency_type" value="NA" bind:group={formData.currency_type} class="accent-green-600" /> Nacional
|
||||
</label>
|
||||
<label class="text-xs flex items-center gap-2 cursor-pointer">
|
||||
<input type="radio" name="inv_currency_type" value="EX" bind:group={formData.currency_type} class="accent-green-600" /> Extranjera
|
||||
</label>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" type="button" onclick={() => { formData.currency_type = 'CATALOG'; showCurrencyModal = true; }} class="w-full justify-between bg-background">
|
||||
<span class="flex items-center gap-2 text-xs"><FolderSearch class="h-3 w-3" /> {selectedCurrencyName || 'Catálogo'}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid gap-2 mt-2">
|
||||
<div class="relative max-w-xs">
|
||||
{#if formData.value_added_type === 'PERCENT'}
|
||||
<span class="absolute right-3 top-2.5 text-muted-foreground">%</span>
|
||||
{:else}
|
||||
<span class="absolute left-3 top-2.5 text-muted-foreground">$</span>
|
||||
{/if}
|
||||
<Input type="number" step="0.0001" id="added_value" bind:value={formData.added_value} class={formData.value_added_type === 'PERCENT' ? 'pr-7' : 'pl-7'} placeholder="0.00"/>
|
||||
|
||||
<Separator class="bg-border/60" />
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-12 gap-6">
|
||||
<div class="md:col-span-8 space-y-3">
|
||||
<Label class="font-semibold text-xs uppercase tracking-wider text-muted-foreground">Tipo Valor Agregado</Label>
|
||||
<div class="flex flex-wrap gap-6 pt-1">
|
||||
<div class="flex items-center space-x-2 bg-background px-3 py-1.5 rounded-md border shadow-sm">
|
||||
<input type="radio" id="va_ext" name="va_type" value="USD" bind:group={formData.value_added_type} class="accent-primary h-4 w-4 cursor-pointer" />
|
||||
<Label for="va_ext" class="font-normal cursor-pointer text-sm">Extranjera (Dls)</Label>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2 bg-background px-3 py-1.5 rounded-md border shadow-sm">
|
||||
<input type="radio" id="va_nac" name="va_type" value="MXN" bind:group={formData.value_added_type} class="accent-primary h-4 w-4 cursor-pointer" />
|
||||
<Label for="va_nac" class="font-normal cursor-pointer text-sm">Nacional (Pesos)</Label>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2 bg-background px-3 py-1.5 rounded-md border shadow-sm">
|
||||
<input type="radio" id="va_pct" name="va_type" value="PERCENT" bind:group={formData.value_added_type} class="accent-primary h-4 w-4 cursor-pointer" />
|
||||
<Label for="va_pct" class="font-normal cursor-pointer text-sm">Porcentaje</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="md:col-span-4 space-y-2">
|
||||
<Label for="added_value">Valor / Porcentaje</Label>
|
||||
<div class="relative">
|
||||
{#if formData.value_added_type === 'PERCENT'}
|
||||
<span class="absolute right-3 top-2.5 text-muted-foreground font-bold">%</span>
|
||||
{:else}
|
||||
<span class="absolute left-3 top-2.5 text-muted-foreground font-semibold">$</span>
|
||||
{/if}
|
||||
<Input type="number" step="0.0001" id="added_value" bind:value={formData.added_value} class="{formData.value_added_type === 'PERCENT' ? 'pr-8' : 'pl-7'} bg-background" placeholder="0.00"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2 pt-4 border-t">
|
||||
<Label for="frac_us">Fracción Americana (HTS)</Label>
|
||||
<Input id="frac_us" bind:value={formData.us_fraction} maxlength={16} placeholder="Ej: 8501.10.00" />
|
||||
<div class="grid grid-cols-1 md:grid-cols-12 gap-6 pt-4 border-t">
|
||||
<div class="md:col-span-6 space-y-2">
|
||||
<Label for="frac_us">Fracción Americana (HTS)</Label>
|
||||
<Input id="frac_us" bind:value={formData.us_fraction} maxlength={16} placeholder="Ej: 8501.10.00" class="font-mono"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</Tabs.Content>
|
||||
|
||||
<Tabs.Content value="opciones" class="space-y-6 pt-4 animate-in fade-in duration-300">
|
||||
<div class="space-y-4 p-4 border rounded-lg bg-card">
|
||||
<Tabs.Content value="opciones" class="space-y-6 pt-6 animate-in fade-in duration-300">
|
||||
<div class="p-6 border rounded-xl space-y-6">
|
||||
<h3 class="font-medium text-sm text-muted-foreground">Configuración General</h3>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div class="grid gap-2">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||
<div class="space-y-2">
|
||||
<Label for="fcc">Clave FCC</Label>
|
||||
<Input id="fcc" bind:value={formData.fcc_key} maxlength={30} />
|
||||
</div>
|
||||
<div class="grid gap-2">
|
||||
<div class="space-y-2">
|
||||
<Label for="supplier">Proveedor (Textil)</Label>
|
||||
<Input id="supplier" bind:value={formData.supplier} maxlength={14} />
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid gap-2">
|
||||
<div class="space-y-2">
|
||||
<Label for="photo" class="flex items-center gap-2"><ImageIcon class="h-4 w-4"/> URL Imagen de la parte</Label>
|
||||
<Input id="photo" bind:value={formData.part_photo} maxlength={255} placeholder="https://..." />
|
||||
</div>
|
||||
</div>
|
||||
</Tabs.Content>
|
||||
|
||||
<Tabs.Content value="opcionales2" class="space-y-6 pt-4 animate-in fade-in duration-300">
|
||||
<div class="p-4 border rounded-lg space-y-4">
|
||||
<h3 class="font-medium text-sm text-muted-foreground">FDA</h3>
|
||||
<div class="grid gap-2">
|
||||
<Label for="fda">Clave FDA</Label>
|
||||
<Input id="fda" bind:value={formData.fda_key} maxlength={20} />
|
||||
<Tabs.Content value="opcionales2" class="space-y-6 pt-6 animate-in fade-in duration-300">
|
||||
<div class="p-6 border rounded-xl space-y-4">
|
||||
<h3 class="font-medium text-sm text-muted-foreground">Regulaciones Sanitarias</h3>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2">
|
||||
<div class="space-y-2">
|
||||
<Label for="fda">Clave FDA</Label>
|
||||
<Input id="fda" bind:value={formData.fda_key} maxlength={20} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Tabs.Content>
|
||||
|
||||
<Tabs.Content value="otros" class="space-y-6 pt-4 animate-in fade-in duration-300">
|
||||
<div class="grid gap-2">
|
||||
<Label for="client_id" class="required">Cliente Asignado</Label>
|
||||
<div class="flex gap-2">
|
||||
<div class="relative flex-1">
|
||||
<UserCheck class="absolute left-3 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input id="client_id" bind:value={selectedClientName} class="pl-9" placeholder="Seleccione un cliente..." readonly onclick={() => showClientModal = true}/>
|
||||
</div>
|
||||
<Button variant="outline" class="shrink-0" type="button" onclick={() => showClientModal = true}><FolderSearch class="h-4 w-4 mr-2" /> Buscar</Button>
|
||||
</div>
|
||||
{#if selectedClientName}
|
||||
<div class="flex items-center gap-2 text-xs">
|
||||
{#if selectedClientStatus}<span class="text-green-600 flex items-center gap-1 font-medium"><CheckCircle2 class="h-3 w-3"/> Activo</span>{:else}<span class="text-red-600 flex items-center gap-1 font-medium"><XCircle class="h-3 w-3"/> Inactivo</span>{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="grid gap-2">
|
||||
<Label for="comm_pn">Número de Parte Comercial</Label>
|
||||
<Input id="comm_pn" bind:value={formData.commercial_part_number} maxlength={70} />
|
||||
</div>
|
||||
<div class="p-4 border rounded-lg space-y-4">
|
||||
<Label for="alt_um">UM Conversión (Alterna)</Label>
|
||||
<div class="flex gap-2">
|
||||
<div class="relative flex-1">
|
||||
<div class="absolute left-3 top-2.5 text-muted-foreground"><Scale class="h-4 w-4" /></div>
|
||||
<Input id="alt_um" bind:value={formData.alternate_unit_measure} placeholder="Seleccione..." class="pl-9 font-mono cursor-pointer" readonly onclick={() => showAltUOMModal = true}/>
|
||||
</div>
|
||||
<Button variant="outline" size="icon" type="button" onclick={() => showAltUOMModal = true}><FolderSearch class="h-4 w-4" /></Button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-4 border rounded-lg space-y-4 bg-slate-50 dark:bg-slate-900/30">
|
||||
<h3 class="font-medium text-sm text-muted-foreground">Datos Regulatorios</h3>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div class="grid gap-2">
|
||||
<Label for="fraction_mx" class="required">Fracción Arancelaria (MX)</Label>
|
||||
<Tabs.Content value="otros" class="space-y-6 pt-6 animate-in fade-in duration-300">
|
||||
<div class="grid grid-cols-1 md:grid-cols-12 gap-8">
|
||||
|
||||
<div class="md:col-span-6 space-y-6">
|
||||
<div class="space-y-2">
|
||||
<Label for="client_id" class="required">Cliente Asignado</Label>
|
||||
<div class="flex gap-2">
|
||||
<Input id="fraction_mx" bind:value={formData.fraction} readonly onclick={() => showFractionModal = true} class="cursor-pointer" placeholder="Seleccione..."/>
|
||||
<Button variant="outline" size="icon" type="button" onclick={() => showFractionModal = true}><FolderSearch class="h-4 w-4" /></Button>
|
||||
<div class="relative w-full">
|
||||
<UserCheck class="absolute left-3 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input id="client_id" bind:value={selectedClientName} class="pl-9 cursor-pointer" placeholder="Seleccione un cliente..." readonly onclick={() => showClientModal = true}/>
|
||||
</div>
|
||||
<Button variant="outline" class="shrink-0" type="button" onclick={() => showClientModal = true}><FolderSearch class="h-4 w-4 mr-2" /> Buscar</Button>
|
||||
</div>
|
||||
{#if selectedClientName}
|
||||
<div class="flex items-center gap-2 text-xs pt-1">
|
||||
{#if selectedClientStatus}<span class="text-green-600 flex items-center gap-1 font-medium"><CheckCircle2 class="h-3 w-3"/> Cliente Activo</span>{:else}<span class="text-red-600 flex items-center gap-1 font-medium"><XCircle class="h-3 w-3"/> Cliente Inactivo</span>{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="comm_pn">Número de Parte Comercial</Label>
|
||||
<Input id="comm_pn" bind:value={formData.commercial_part_number} maxlength={70} />
|
||||
</div>
|
||||
|
||||
<div class="p-4 border rounded-xl space-y-3 bg-muted/20">
|
||||
<Label for="alt_um" class="flex items-center gap-2"><Scale class="h-4 w-4"/> UM Conversión (Alterna)</Label>
|
||||
<div class="flex gap-2">
|
||||
<Input id="alt_um" bind:value={formData.alternate_unit_measure} placeholder="Seleccione..." class="font-mono cursor-pointer" readonly onclick={() => showAltUOMModal = true}/>
|
||||
<Button variant="outline" size="icon" type="button" onclick={() => showAltUOMModal = true} class="shrink-0"><FolderSearch class="h-4 w-4" /></Button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid gap-2">
|
||||
<Label for="eccn">ECCN</Label>
|
||||
<Input id="eccn" bind:value={formData.eccn} maxlength={20} />
|
||||
</div>
|
||||
|
||||
<div class="md:col-span-6 space-y-6">
|
||||
<div class="p-6 border rounded-xl space-y-4 bg-slate-50 dark:bg-slate-900/30">
|
||||
<h3 class="font-medium text-sm text-muted-foreground">Datos Regulatorios Adicionales</h3>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="fraction_mx" class="required">Fracción Arancelaria (MX)</Label>
|
||||
<div class="flex gap-2">
|
||||
<Input id="fraction_mx" bind:value={formData.fraction} readonly onclick={() => showFractionModal = true} class="cursor-pointer font-mono" placeholder="Seleccione..."/>
|
||||
<Button variant="outline" size="icon" type="button" onclick={() => showFractionModal = true} class="shrink-0"><FolderSearch class="h-4 w-4" /></Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="eccn">ECCN</Label>
|
||||
<Input id="eccn" bind:value={formData.eccn} maxlength={20} class="font-mono"/>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="lic_code">License Code</Label>
|
||||
<Input id="lic_code" bind:value={formData.license_code} maxlength={3} class="font-mono"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="exp_code">Export Code</Label>
|
||||
<Input id="exp_code" bind:value={formData.export_code} maxlength={2} class="font-mono"/>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="exc_sym">Símbolo Exclusión</Label>
|
||||
<Input id="exc_sym" bind:value={formData.exclusion_symbol} maxlength={19} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid gap-2">
|
||||
<Label for="lic_code">License Code</Label>
|
||||
<Input id="lic_code" bind:value={formData.license_code} maxlength={3} />
|
||||
</div>
|
||||
<div class="grid gap-2">
|
||||
<Label for="exp_code">Export Code</Label>
|
||||
<Input id="exp_code" bind:value={formData.export_code} maxlength={2} />
|
||||
</div>
|
||||
<div class="grid gap-2">
|
||||
<Label for="exc_sym">Símbolo de Exclusión</Label>
|
||||
<Input id="exc_sym" bind:value={formData.exclusion_symbol} maxlength={19} />
|
||||
|
||||
<div class="flex items-center gap-3 p-4 border rounded-xl">
|
||||
<Switch id="is_active" bind:checked={formData.is_active} disabled={loading} />
|
||||
<Label for="is_active" class="cursor-pointer">Parte Activa en Sistema</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 p-4 border rounded-lg bg-card">
|
||||
<Switch id="is_active" bind:checked={formData.is_active} disabled={loading} />
|
||||
<Label for="is_active">Parte Activa en Sistema</Label>
|
||||
|
||||
</div>
|
||||
</Tabs.Content>
|
||||
</div>
|
||||
|
||||
@@ -1,255 +1,382 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { clientsProvidersApi, type ClientProvider } from '$lib/api/dashboard/a76/clients-providers';
|
||||
import DataTable from '$lib/components/dashboard/clients_and_providers/data-table.svelte';
|
||||
import { createColumns } from '$lib/components/dashboard/clients_and_providers/columns.js';
|
||||
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 * as Select from '$lib/components/ui/select';
|
||||
import { Plus, RefreshCw } from 'lucide-svelte';
|
||||
import { page } from '$app/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
import type { PageData } from './$types';
|
||||
import { browser } from '$app/environment';
|
||||
import { Plus, RefreshCw, Users, Building2, MapPin, Mail, Phone, 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';
|
||||
|
||||
// Los datos iniciales vienen del servidor
|
||||
let { data }: { data: PageData } = $props();
|
||||
let { data }: { data: any } = $props();
|
||||
|
||||
// State
|
||||
let items = $state<ClientProvider[]>(data.items || []);
|
||||
let selectedItem = $state<ClientProvider | null>(null);
|
||||
let isLoading = $state(false);
|
||||
|
||||
// Estado para el diálogo de crear
|
||||
let showCreateDialog = $state(false);
|
||||
// Server-side filtering/pagination parameters
|
||||
let currentPage = $state(1);
|
||||
let pageSize = $state(50);
|
||||
let totalItems = $state(data.total || 0);
|
||||
|
||||
// Estado para el filtro de tipo
|
||||
let selectedType = $state<string>($page.url.searchParams.get('type') || 'both');
|
||||
// Filter state
|
||||
let searchName = $state('');
|
||||
let searchRfc = $state('');
|
||||
let searchType = $state<string>($page.url.searchParams.get('type') || 'both');
|
||||
|
||||
// Actualizar URL cuando cambia el filtro
|
||||
function handleTypeChange(value: string) {
|
||||
selectedType = value;
|
||||
const url = new URL(window.location.href);
|
||||
if (value === 'both') {
|
||||
url.searchParams.delete('type');
|
||||
} else {
|
||||
url.searchParams.set('type', value);
|
||||
}
|
||||
goto(url.toString(), { keepFocus: true, noScroll: true, replaceState: true });
|
||||
reloadData();
|
||||
}
|
||||
|
||||
// Sincronizar token de cookies a localStorage al montar el componente
|
||||
// --- Lifecycle ---
|
||||
|
||||
onMount(() => {
|
||||
if (browser) {
|
||||
// Función para obtener el valor de una cookie
|
||||
const getCookie = (name: string): string | null => {
|
||||
// Sincronizar token de cookies a localStorage si es necesario
|
||||
const getCookie = (name: string) => {
|
||||
const value = `; ${document.cookie}`;
|
||||
const parts = value.split(`; ${name}=`);
|
||||
if (parts.length === 2) return parts.pop()?.split(';').shift() || null;
|
||||
return null;
|
||||
};
|
||||
|
||||
// Verificar si hay token en las cookies
|
||||
const cookieToken = getCookie('access_token');
|
||||
const localToken = localStorage.getItem('access_token');
|
||||
if (cookieToken && cookieToken !== localToken) localStorage.setItem('access_token', cookieToken);
|
||||
|
||||
if (cookieToken && cookieToken !== localToken) {
|
||||
localStorage.setItem('access_token', cookieToken);
|
||||
}
|
||||
|
||||
// También sincronizar refresh_token si existe
|
||||
const cookieRefreshToken = getCookie('refresh_token');
|
||||
const localRefreshToken = localStorage.getItem('refresh_token');
|
||||
|
||||
if (cookieRefreshToken && cookieRefreshToken !== localRefreshToken) {
|
||||
localStorage.setItem('refresh_token', cookieRefreshToken);
|
||||
}
|
||||
|
||||
// Escuchar cambios de compañía
|
||||
const handleCompanyChange = (event: CustomEvent) => {
|
||||
// Recargar la página para obtener datos de la nueva compañía
|
||||
reloadData();
|
||||
};
|
||||
|
||||
window.addEventListener('companyChanged', handleCompanyChange as EventListener);
|
||||
|
||||
// Cleanup
|
||||
return () => {
|
||||
window.removeEventListener('companyChanged', handleCompanyChange as EventListener);
|
||||
};
|
||||
// Listen for company changes
|
||||
const handleCompanyChange = () => loadItems(1); // Reload from page 1
|
||||
window.addEventListener('companyChanged', handleCompanyChange);
|
||||
return () => window.removeEventListener('companyChanged', handleCompanyChange);
|
||||
}
|
||||
});
|
||||
|
||||
// Estado para infinite scroll
|
||||
let allItems = $state<ClientProvider[]>(data.items || []);
|
||||
let currentPage = $state(data.page || 1);
|
||||
let pageSize = $state(50);
|
||||
let totalItems = $state(data.total || 0);
|
||||
let loading = $state(false);
|
||||
let hasMore = $derived(allItems.length < totalItems);
|
||||
let error = $state<string | null>(data.error || null);
|
||||
// --- Actions ---
|
||||
|
||||
async function loadMore() {
|
||||
if (loading || !hasMore || !companyStore.activeCompany) return;
|
||||
|
||||
loading = true;
|
||||
error = null;
|
||||
|
||||
try {
|
||||
const response = await clientsProvidersApi.list(
|
||||
companyStore.activeCompany.id,
|
||||
currentPage + 1,
|
||||
pageSize,
|
||||
selectedType !== 'both' ? { type: selectedType } : undefined
|
||||
);
|
||||
|
||||
if (response.error) {
|
||||
console.error('📊 [Page] Error en loadMore:', response.error, 'Status:', response.status);
|
||||
|
||||
// Si es un error de autenticación (401 o 403) y no se pudo refrescar, mostrar mensaje específico
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
error = 'Sesión expirada. Recargando página...';
|
||||
// Recargar automáticamente después de 2 segundos
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 2000);
|
||||
} else {
|
||||
error = response.error;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (response.data?.items) {
|
||||
// Agregar los nuevos items al array existente
|
||||
allItems = [...allItems, ...response.data.items];
|
||||
currentPage++;
|
||||
totalItems = response.data.total;
|
||||
}
|
||||
} catch (e) {
|
||||
error = 'Error cargando más datos';
|
||||
console.error('📊 [Page] Error loading more:', e);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function reloadData() {
|
||||
// Reset y recargar desde el principio usando la API
|
||||
if (!companyStore.activeCompany) return;
|
||||
|
||||
loading = true;
|
||||
error = null;
|
||||
async function loadItems(pageToLoad = 1) {
|
||||
const companyId = companyStore.activeCompany?.id;
|
||||
if (!companyId) return;
|
||||
|
||||
isLoading = true;
|
||||
try {
|
||||
const response = await clientsProvidersApi.list(
|
||||
companyStore.activeCompany.id,
|
||||
1,
|
||||
pageSize,
|
||||
selectedType !== 'both' ? { type: selectedType } : undefined
|
||||
);
|
||||
const filters: any = {};
|
||||
if (searchType !== 'both') filters.type = searchType;
|
||||
// Note: The API technically supports name/rfc fitlering if backend implements it.
|
||||
// Assuming backend supports 'name' and 'rfc' query params based on standard patterns,
|
||||
// or we filter client side if the list is small.
|
||||
// Given pagination, we should try sending them. If backend ignores them, we might need client filtering.
|
||||
// Ideally backend should handle this. I will assume backend filters for now or add query params.
|
||||
if (searchName) filters.name = searchName;
|
||||
if (searchRfc) filters.rfc = searchRfc;
|
||||
|
||||
const response = await clientsProvidersApi.list(companyId, pageToLoad, pageSize, filters);
|
||||
|
||||
if (response.error) {
|
||||
console.error('📊 [Page] Error en reloadData:', response.error);
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
error = 'Sesión expirada. Recargando página...';
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 2000);
|
||||
} else {
|
||||
error = response.error;
|
||||
toast.error('Sesión expirada. Recargando...');
|
||||
setTimeout(() => window.location.reload(), 2000);
|
||||
return;
|
||||
}
|
||||
toast.error(response.error);
|
||||
return;
|
||||
}
|
||||
|
||||
if (response.data?.items) {
|
||||
// Reemplazar todos los items con los nuevos datos
|
||||
allItems = response.data.items;
|
||||
currentPage = 1;
|
||||
if (response.data) {
|
||||
items = response.data.items;
|
||||
totalItems = response.data.total;
|
||||
currentPage = response.data.page;
|
||||
}
|
||||
} catch (e) {
|
||||
error = 'Error recargando datos';
|
||||
console.error('📊 [Page] Error reloading:', e);
|
||||
console.error('Error loading items:', e);
|
||||
toast.error('Error al cargar datos');
|
||||
} finally {
|
||||
loading = false;
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleCreateClick() {
|
||||
showCreateDialog = true;
|
||||
function handleTypeChange(value: string) {
|
||||
searchType = value;
|
||||
loadItems(1);
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
loadItems(1);
|
||||
}
|
||||
|
||||
function handleSuccess() {
|
||||
// Recargar datos después de crear/editar/eliminar
|
||||
reloadData();
|
||||
function selectItem(item: ClientProvider) {
|
||||
selectedItem = item;
|
||||
}
|
||||
|
||||
function handleEdit() {
|
||||
if (selectedItem) goto(`/dashboard/clients_and_providers/edit/${selectedItem.id}`);
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (!selectedItem || !companyStore.activeCompany?.id) return;
|
||||
if (!confirm('¿Estás seguro de eliminar este registro?')) return;
|
||||
|
||||
try {
|
||||
await clientsProvidersApi.delete(selectedItem.id, companyStore.activeCompany.id);
|
||||
toast.success('Registro eliminado');
|
||||
selectedItem = null;
|
||||
loadItems(currentPage);
|
||||
} catch (e) {
|
||||
toast.error('Error al eliminar');
|
||||
}
|
||||
}
|
||||
|
||||
// Crear columnas con el callback onSuccess
|
||||
const columns = createColumns(handleSuccess);
|
||||
</script>
|
||||
|
||||
<div class="space-y-6">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold tracking-tight">Clientes y Proveedores</h1>
|
||||
<p class="text-muted-foreground">
|
||||
Gestiona el catálogo de clientes y proveedores de tu empresa
|
||||
</p>
|
||||
</div>
|
||||
<Button href="/dashboard/clients_and_providers/edit">
|
||||
<Plus class="mr-2" size={16} />
|
||||
Nuevo Cliente/Proveedor
|
||||
</Button>
|
||||
<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">CLIENTES Y PROVEEDORES</h1>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
Gestiona el catálogo de clientes y proveedores de tu empresa
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Error Message -->
|
||||
{#if error}
|
||||
<Card.Root class="border-destructive">
|
||||
<Card.Header>
|
||||
<Card.Title class="text-destructive">Error</Card.Title>
|
||||
<Card.Description>{error}</Card.Description>
|
||||
</Card.Header>
|
||||
</Card.Root>
|
||||
{/if}
|
||||
|
||||
<!-- Data Table -->
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<Card.Title>Listado de Clientes y Proveedores</Card.Title>
|
||||
<Card.Description>
|
||||
Mostrando {allItems.length} de {totalItems} registros
|
||||
{#if companyStore.activeCompany}
|
||||
- Compañía: {companyStore.activeCompany.name}
|
||||
{/if}
|
||||
</Card.Description>
|
||||
<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, RFC o tipo</span>
|
||||
</div>
|
||||
<div class="grid grid-cols-4 gap-4">
|
||||
<div class="space-y-2">
|
||||
<Label class="text-xs">Nombre / Razón Social</Label>
|
||||
<Input
|
||||
bind:value={searchName}
|
||||
placeholder="Buscar por nombre..."
|
||||
class="h-9"
|
||||
onkeydown={(e) => e.key === 'Enter' && handleSearch()}
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label class="text-xs">RFC</Label>
|
||||
<Input
|
||||
bind:value={searchRfc}
|
||||
placeholder="RFC..."
|
||||
class="h-9"
|
||||
onkeydown={(e) => e.key === 'Enter' && handleSearch()}
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label class="text-xs">Tipo</Label>
|
||||
<Select.Root type="single" value={searchType} onValueChange={handleTypeChange}>
|
||||
<Select.Trigger class="h-9">
|
||||
{searchType === 'both' ? 'Todos' : searchType === 'client' ? 'Clientes' : 'Proveedores'}
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
<Select.Item value="both">Todos</Select.Item>
|
||||
<Select.Item value="client">Clientes</Select.Item>
|
||||
<Select.Item value="provider">Proveedores</Select.Item>
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
</div>
|
||||
<div class="flex items-end">
|
||||
<Button variant="secondary" size="sm" class="w-full" onclick={handleSearch}>
|
||||
Buscar
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Select.Root type="single" value={selectedType} onValueChange={handleTypeChange}>
|
||||
<Select.Trigger class="w-[180px]">
|
||||
{selectedType === 'both' ? 'Todos' : selectedType === 'client' ? 'Clientes' : 'Proveedores'}
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
<Select.Item value="both">Todos</Select.Item>
|
||||
<Select.Item value="client">Clientes</Select.Item>
|
||||
<Select.Item value="provider">Proveedores</Select.Item>
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
<Button variant="outline" onclick={reloadData}>
|
||||
<RefreshCw class="mr-2" size={16} />
|
||||
Actualizar
|
||||
</Button>
|
||||
</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">
|
||||
{totalItems} registros
|
||||
</span>
|
||||
<Button variant="outline" size="sm" onclick={() => loadItems(currentPage)}>
|
||||
<RefreshCw class="h-4 w-4 mr-2" />
|
||||
Actualizar
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-auto bg-card">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="bg-muted text-muted-foreground sticky top-0 z-10 border-b">
|
||||
<tr>
|
||||
<th class="px-3 py-2 text-left w-8">#</th>
|
||||
<th class="px-3 py-2 text-left">RFC</th>
|
||||
<th class="px-3 py-2 text-left">Nombre</th>
|
||||
<th class="px-3 py-2 text-left">Tipo</th>
|
||||
<th class="px-3 py-2 text-left">Estatus</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#if isLoading}
|
||||
<tr><td colspan="5" class="text-center py-8 text-muted-foreground">Cargando...</td></tr>
|
||||
{:else if items.length === 0}
|
||||
<tr><td colspan="5" class="text-center py-8 text-muted-foreground">No se encontraron registros</td></tr>
|
||||
{:else}
|
||||
{#each items as item (item.id)}
|
||||
<tr
|
||||
class="border-b cursor-pointer transition-colors hover:bg-muted/50 {selectedItem?.id === item.id ? 'bg-muted' : ''}"
|
||||
onclick={() => selectItem(item)}
|
||||
>
|
||||
<td class="px-3 py-2 font-mono text-xs text-muted-foreground">{item.id}</td>
|
||||
<td class="px-3 py-2 font-mono font-medium">{item.rfc}</td>
|
||||
<td class="px-3 py-2">{item.name}</td>
|
||||
<td class="px-3 py-2">
|
||||
{#if item.client_or_provider === 'client'}
|
||||
<span class="inline-flex items-center rounded-full bg-blue-100 px-2 py-0.5 text-xs font-medium text-blue-700">Cliente</span>
|
||||
{:else if item.client_or_provider === 'provider'}
|
||||
<span class="inline-flex items-center rounded-full bg-purple-100 px-2 py-0.5 text-xs font-medium text-purple-700">Proveedor</span>
|
||||
{:else}
|
||||
<span class="inline-flex items-center rounded-full bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-700">Ambos</span>
|
||||
{/if}
|
||||
</td>
|
||||
<td class="px-3 py-2">
|
||||
<span class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium {item.is_active ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'}">
|
||||
{item.is_active ? 'Activo' : 'Inactivo'}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
{/if}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!-- Simple Pagination Controls -->
|
||||
<div class="p-2 border-t flex justify-end gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={currentPage === 1 || isLoading}
|
||||
onclick={() => loadItems(currentPage - 1)}
|
||||
>
|
||||
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={items.length < pageSize || isLoading}
|
||||
onclick={() => loadItems(currentPage + 1)}
|
||||
>
|
||||
Siguiente
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
<!-- TanStack DataTable con Infinite Scroll -->
|
||||
<DataTable
|
||||
data={allItems}
|
||||
{columns}
|
||||
{loading}
|
||||
{hasMore}
|
||||
{loadMore}
|
||||
/>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
</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 Registro</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">{selectedItem?.rfc || ''}</span>
|
||||
</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">
|
||||
<Users size={10} /> Tipo
|
||||
</Label>
|
||||
<p class="text-sm font-medium capitalize">{selectedItem.client_or_provider}</p>
|
||||
</div>
|
||||
|
||||
{#if selectedItem.address}
|
||||
<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.streets || ''} {selectedItem.address.exterior_number || ''} {selectedItem.address.interior_number ? 'Int ' + selectedItem.address.interior_number : ''}</p>
|
||||
<p>{selectedItem.address.neighborhood || ''}</p>
|
||||
<p>{selectedItem.address.city || ''}, {selectedItem.address.state || ''}</p>
|
||||
<p>{selectedItem.address.postal_code || ''}, {selectedItem.address.country || ''}</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">
|
||||
<Hash size={10} /> Contacto
|
||||
</Label>
|
||||
{#if selectedItem.address.email}
|
||||
<div class="flex items-center gap-2 text-sm">
|
||||
<Mail size={14} class="text-muted-foreground" />
|
||||
<span>{selectedItem.address.email}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if selectedItem.address.phone}
|
||||
<div class="flex items-center gap-2 text-sm">
|
||||
<Phone size={14} class="text-muted-foreground" />
|
||||
<span>{selectedItem.address.phone}</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="p-4 rounded-md bg-yellow-50 dark:bg-yellow-900/10 border border-yellow-200 dark:border-yellow-900">
|
||||
<p class="text-xs text-yellow-600 dark:text-yellow-400">Sin dirección registrada</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if selectedItem.programs}
|
||||
<div class="pt-4 border-t space-y-3">
|
||||
<Label class="text-[10px] uppercase text-muted-foreground font-bold flex items-center gap-1">
|
||||
<Building2 size={10} /> Programas
|
||||
</Label>
|
||||
<div class="grid grid-cols-2 gap-2 text-sm">
|
||||
<div>
|
||||
<span class="text-xs text-muted-foreground block">Programa</span>
|
||||
<span class="font-medium">{selectedItem.programs.program || '-'}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-xs text-muted-foreground block">Número</span>
|
||||
<span class="font-medium">{selectedItem.programs.program_number || '-'}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-xs text-muted-foreground block">TAX ID</span>
|
||||
<span class="font-medium">{selectedItem.programs.tax_id || '-'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</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 registro</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</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/clients_and_providers/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>
|
||||
|
||||
@@ -11,7 +11,10 @@
|
||||
import * as Select from "$lib/components/ui/select";
|
||||
import * as Tabs from "$lib/components/ui/tabs";
|
||||
import { Switch } from "$lib/components/ui/switch";
|
||||
import { LoaderCircle, ArrowLeft, Save, Building2, MapPin, FileText, Settings } from 'lucide-svelte';
|
||||
import { Badge } from '$lib/components/ui/badge';
|
||||
import { Separator } from '$lib/components/ui/separator';
|
||||
import { LoaderCircle, ArrowLeft, Save, Building2, MapPin, FileText, Settings, User, Trash2 } from 'lucide-svelte';
|
||||
import { toast } from 'svelte-sonner';
|
||||
|
||||
// API & Stores
|
||||
import { clientsProvidersApi } from "$lib/api/dashboard/a76/clients-providers";
|
||||
@@ -20,7 +23,9 @@
|
||||
// --- CONFIGURACIÓN ---
|
||||
let id = $derived($page.params.id);
|
||||
let isEditing = $derived(!!id);
|
||||
let title = $derived(isEditing ? "Editar Socio Comercial" : "Nuevo Socio Comercial");
|
||||
|
||||
// State for Tabs
|
||||
let activeTab = $state('general');
|
||||
|
||||
const typeLabels: Record<string, string> = {
|
||||
client: "Cliente",
|
||||
@@ -130,6 +135,7 @@
|
||||
}
|
||||
} catch (e: any) {
|
||||
error = "Error al cargar: " + e.message;
|
||||
toast.error(error);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
@@ -137,8 +143,8 @@
|
||||
|
||||
// --- ENVÍO DE DATOS ---
|
||||
async function handleSubmit() {
|
||||
if (!companyStore.activeCompany) { error = "Selecciona una compañía"; return; }
|
||||
if (!formData.rfc.trim() || !formData.name.trim()) { error = "RFC y Nombre obligatorios"; return; }
|
||||
if (!companyStore.activeCompany) { error = "Selecciona una compañía"; toast.error(error); return; }
|
||||
if (!formData.rfc.trim() || !formData.name.trim()) { error = "RFC y Nombre obligatorios"; toast.error(error); return; }
|
||||
|
||||
loading = true;
|
||||
error = null;
|
||||
@@ -191,42 +197,63 @@
|
||||
|
||||
if (response.error) throw new Error(response.error);
|
||||
|
||||
toast.success(isEditing ? 'Registro actualizado correctamente' : 'Registro creado correctamente');
|
||||
goto('/dashboard/clients_and_providers');
|
||||
|
||||
} catch (e: any) {
|
||||
console.error(e);
|
||||
error = e.message || "Error al guardar";
|
||||
toast.error(error);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
goto('/dashboard/clients_and_providers');
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="w-full mx-auto max-w-6xl py-6 px-4 space-y-6 pb-48">
|
||||
<div class="flex items-center gap-4">
|
||||
<Button variant="outline" size="icon" href="/dashboard/clients_and_providers">
|
||||
<ArrowLeft class="h-4 w-4" />
|
||||
</Button>
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold tracking-tight">{title}</h1>
|
||||
<p class="text-muted-foreground">Gestión integral del catálogo de socios comerciales.</p>
|
||||
<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">
|
||||
{isEditing ? `Socio Comercial #${id}` : 'Nuevo Socio Comercial'}
|
||||
</h1>
|
||||
{#if isEditing}
|
||||
<Badge variant={formData.is_active ? 'default' : 'secondary'}>
|
||||
{formData.is_active ? 'Activo' : 'Inactivo'}
|
||||
</Badge>
|
||||
{:else}
|
||||
<Badge variant="default">Nuevo</Badge>
|
||||
{/if}
|
||||
</div>
|
||||
<p class="text-muted-foreground ml-12">
|
||||
{isEditing ? 'Edita la información del cliente o proveedor' : 'Registra un nuevo cliente o proveedor en el sistema'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<div class="p-4 rounded-md bg-destructive/10 text-destructive border border-destructive/20 text-sm font-medium">
|
||||
⚠️ {error}
|
||||
</div>
|
||||
{/if}
|
||||
<Separator />
|
||||
|
||||
<form onsubmit={(e) => { e.preventDefault(); handleSubmit(); }} class="space-y-6">
|
||||
<Card.Root>
|
||||
<Card.Content class="p-6">
|
||||
<Tabs.Root value="general" class="w-full">
|
||||
|
||||
<div class="min-h-[400px]">
|
||||
|
||||
<Tabs.Content value="general" class="space-y-4 pt-4">
|
||||
<!-- Main Content with Tabs -->
|
||||
<div class="pb-48"> <!-- Padding bottom for sticky footer -->
|
||||
<form onsubmit={(e) => { e.preventDefault(); handleSubmit(); }}>
|
||||
<Tabs.Root bind:value={activeTab}>
|
||||
|
||||
<!-- Tab Panel: General -->
|
||||
<Tabs.Content value="general">
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title>Información General</Card.Title>
|
||||
<Card.Description>Datos principales de identificación y clasificación.</Card.Description>
|
||||
</Card.Header>
|
||||
<Card.Content class="space-y-4">
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div class="grid gap-2">
|
||||
<Label for="rfc">RFC <span class="text-destructive">*</span></Label>
|
||||
@@ -287,9 +314,18 @@
|
||||
<Input id="position" bind:value={formData.position} disabled={loading} />
|
||||
</div>
|
||||
</div>
|
||||
</Tabs.Content>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
</Tabs.Content>
|
||||
|
||||
<Tabs.Content value="address" class="space-y-4 pt-4">
|
||||
<!-- Tab Panel: Address -->
|
||||
<Tabs.Content value="address">
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title>Dirección y Contacto</Card.Title>
|
||||
<Card.Description>Ubicación fiscal y datos de contacto.</Card.Description>
|
||||
</Card.Header>
|
||||
<Card.Content class="space-y-4">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div class="grid gap-2">
|
||||
<Label for="street">Calle</Label>
|
||||
@@ -337,7 +373,9 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 pt-4 border-t">
|
||||
<Separator class="my-4" />
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div class="grid gap-2">
|
||||
<Label for="email">Email de Contacto</Label>
|
||||
<Input id="email" type="email" bind:value={formData.email} disabled={loading} />
|
||||
@@ -347,9 +385,18 @@
|
||||
<Input id="phone" bind:value={formData.phone} disabled={loading} />
|
||||
</div>
|
||||
</div>
|
||||
</Tabs.Content>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
</Tabs.Content>
|
||||
|
||||
<Tabs.Content value="programs" class="space-y-4 pt-4">
|
||||
<!-- Tab Panel: Programs -->
|
||||
<Tabs.Content value="programs">
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title>Programas y Certificaciones</Card.Title>
|
||||
<Card.Description>Información sobre IMMEX, PROSEC y otras certificaciones.</Card.Description>
|
||||
</Card.Header>
|
||||
<Card.Content class="space-y-4">
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div class="grid gap-2">
|
||||
<Label for="program">Programa</Label>
|
||||
@@ -386,9 +433,18 @@
|
||||
<Input id="prosec" type="number" bind:value={formData.prosec} disabled={loading} />
|
||||
</div>
|
||||
</div>
|
||||
</Tabs.Content>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
</Tabs.Content>
|
||||
|
||||
<Tabs.Content value="config" class="space-y-4 pt-4">
|
||||
<!-- Tab Panel: Settings -->
|
||||
<Tabs.Content value="config">
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title>Configuración</Card.Title>
|
||||
<Card.Description>Ajustes de estado y atributos especiales.</Card.Description>
|
||||
</Card.Header>
|
||||
<Card.Content class="space-y-4">
|
||||
<div class="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
<div class="flex items-center gap-3 p-4 border rounded-lg bg-card">
|
||||
<Switch id="is_active" bind:checked={formData.is_active} disabled={loading} />
|
||||
@@ -405,49 +461,57 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Tabs.Content>
|
||||
</div>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
</Tabs.Content>
|
||||
</Tabs.Root>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tabs.List class="grid grid-cols-4 fixed bottom-24 left-1/2 -translate-x-1/2 w-[95%] max-w-2xl z-40 shadow-2xl bg-background border p-1 rounded-xl">
|
||||
<Tabs.Trigger value="general" class="flex gap-2 items-center justify-center">
|
||||
<Building2 class="h-4 w-4 hidden sm:block" /> General
|
||||
</Tabs.Trigger>
|
||||
<Tabs.Trigger value="address" class="flex gap-2 items-center justify-center">
|
||||
<MapPin class="h-4 w-4 hidden sm:block" /> Dirección
|
||||
</Tabs.Trigger>
|
||||
<Tabs.Trigger value="programs" class="flex gap-2 items-center justify-center">
|
||||
<FileText class="h-4 w-4 hidden sm:block" /> Programas
|
||||
</Tabs.Trigger>
|
||||
<Tabs.Trigger value="config" class="flex gap-2 items-center justify-center">
|
||||
<Settings class="h-4 w-4 hidden sm:block" /> Config
|
||||
</Tabs.Trigger>
|
||||
</Tabs.List>
|
||||
|
||||
</Tabs.Root>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
|
||||
<div class="fixed bottom-0 right-0 left-0 md:left-64 p-4 border-t bg-background/95 backdrop-blur z-50">
|
||||
<div class="max-w-6xl mx-auto flex justify-end gap-4">
|
||||
<Button type="button" variant="ghost" onclick={() => goto('/dashboard/clients_and_providers')} disabled={loading}>
|
||||
Cancelar
|
||||
</Button>
|
||||
|
||||
{#if !isEditing}
|
||||
<Button type="button" variant="outline" onclick={() => formData = getEmptyForm()} disabled={loading}>
|
||||
Limpiar
|
||||
</Button>
|
||||
{/if}
|
||||
|
||||
<Button type="submit" disabled={loading} class="min-w-[140px]">
|
||||
{#if loading}
|
||||
<LoaderCircle class="mr-2 h-4 w-4 animate-spin" />
|
||||
{:else}
|
||||
<Save class="mr-2 h-4 w-4" />
|
||||
{/if}
|
||||
{isEditing ? 'Actualizar' : 'Guardar'}
|
||||
</Button>
|
||||
<!-- 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">
|
||||
<!-- Tabs Navigation in Footer -->
|
||||
<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-4">
|
||||
<Tabs.Trigger value="general" class="whitespace-nowrap">
|
||||
<Building2 size={16} class="mr-2" /> General
|
||||
</Tabs.Trigger>
|
||||
<Tabs.Trigger value="address" class="whitespace-nowrap">
|
||||
<MapPin size={16} class="mr-2" /> Dirección
|
||||
</Tabs.Trigger>
|
||||
<Tabs.Trigger value="programs" class="whitespace-nowrap">
|
||||
<FileText size={16} class="mr-2" /> Programas
|
||||
</Tabs.Trigger>
|
||||
<Tabs.Trigger value="config" class="whitespace-nowrap">
|
||||
<Settings size={16} class="mr-2" /> Config
|
||||
</Tabs.Trigger>
|
||||
</Tabs.List>
|
||||
</div>
|
||||
</Tabs.Root>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex justify-end gap-3">
|
||||
<Button variant="outline" onclick={handleCancel} disabled={loading}>
|
||||
Cancelar
|
||||
</Button>
|
||||
{#if !isEditing}
|
||||
<Button variant="outline" onclick={() => formData = getEmptyForm()} disabled={loading}>
|
||||
<!-- <Trash2 size={16} class="mr-2" /> -->
|
||||
Limpiar
|
||||
</Button>
|
||||
{/if}
|
||||
<Button onclick={handleSubmit} disabled={loading}>
|
||||
{#if loading}
|
||||
<LoaderCircle class="mr-2 h-4 w-4 animate-spin" />
|
||||
Guardando...
|
||||
{:else}
|
||||
<Save size={16} class="mr-2" />
|
||||
{isEditing ? 'Actualizar Socio' : 'Guardar Socio'}
|
||||
{/if}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,274 +1,333 @@
|
||||
<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 { Plus, RefreshCw, Building2, MapPin, Mail, Phone, FileText, Hash } from 'lucide-svelte';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { companyStore } from '$lib/stores/company.svelte';
|
||||
import { Plus, Search, Trash2, Loader2 } from 'lucide-svelte';
|
||||
import { browser } from '$app/environment';
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/stores';
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
let showCreateDialog = $state(false);
|
||||
|
||||
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);
|
||||
let { data }: { data: any } = $props();
|
||||
|
||||
// State
|
||||
let items = $state<CustomsBroker[]>(data.items || []);
|
||||
let selectedItem = $state<CustomsBroker | null>(null);
|
||||
let isLoading = $state(false);
|
||||
|
||||
let lastLoadedCompanyId = $state<string>(data.currentCompanyId?.toString() || "");
|
||||
// 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('');
|
||||
|
||||
$effect(() => {
|
||||
const currentId = companyStore.activeCompany?.id?.toString();
|
||||
|
||||
|
||||
if (browser && currentId && currentId !== lastLoadedCompanyId) {
|
||||
lastLoadedCompanyId = currentId;
|
||||
|
||||
reloadData(false);
|
||||
}
|
||||
});
|
||||
// Pagination state
|
||||
let currentPage = $state(1);
|
||||
let pageSize = $state(50);
|
||||
|
||||
// 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;
|
||||
})
|
||||
);
|
||||
|
||||
let paginatedItems = $derived(
|
||||
filteredItems.slice((currentPage - 1) * pageSize, currentPage * pageSize)
|
||||
);
|
||||
|
||||
let totalItems = $derived(filteredItems.length);
|
||||
|
||||
// --- Lifecycle ---
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
const cookieToken = getCookie('access_token');
|
||||
const localToken = localStorage.getItem('access_token');
|
||||
if (cookieToken && cookieToken !== localToken) localStorage.setItem('access_token', cookieToken);
|
||||
|
||||
const cookieRefreshToken = getCookie('refresh_token');
|
||||
const localRefreshToken = localStorage.getItem('refresh_token');
|
||||
if (cookieRefreshToken && cookieRefreshToken !== localRefreshToken) localStorage.setItem('refresh_token', cookieRefreshToken);
|
||||
const handleCompanyChange = () => loadItems();
|
||||
window.addEventListener('companyChanged', handleCompanyChange);
|
||||
return () => window.removeEventListener('companyChanged', handleCompanyChange);
|
||||
}
|
||||
});
|
||||
|
||||
async function handleSearch() {
|
||||
if (!searchKey.trim()) {
|
||||
searchError = 'Por favor ingresa una clave de agente aduanal';
|
||||
return;
|
||||
}
|
||||
// --- Actions ---
|
||||
|
||||
searchLoading = true;
|
||||
searchError = null;
|
||||
searchedBroker = null;
|
||||
async function loadItems() {
|
||||
const companyId = companyStore.activeCompany?.id;
|
||||
if (!companyId) return;
|
||||
|
||||
isLoading = true;
|
||||
try {
|
||||
const companyId = companyStore.activeCompany?.id.toString() || '';
|
||||
const response = await customsBrokersApi.get(searchKey.trim(), companyId);
|
||||
|
||||
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;
|
||||
const response = await customsBrokersApi.list(companyId.toString());
|
||||
|
||||
// Handle response wrapper
|
||||
if ((response as any).error) {
|
||||
toast.error((response as any).error);
|
||||
return;
|
||||
}
|
||||
|
||||
if (response.data) {
|
||||
searchedBroker = response.data;
|
||||
// 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;
|
||||
}
|
||||
} catch (e) {
|
||||
searchError = 'Error al buscar agente aduanal';
|
||||
console.error('Error searching broker:', e);
|
||||
|
||||
currentPage = 1;
|
||||
|
||||
} catch (e: any) {
|
||||
console.error('Error loading items:', e);
|
||||
toast.error('Error al cargar datos');
|
||||
} finally {
|
||||
searchLoading = false;
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
currentPage = 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recarga los datos.
|
||||
* @param silent Si es true, NO bloquea la tabla con el spinner
|
||||
*/
|
||||
async function reloadData(silent = false) {
|
||||
searchKey = '';
|
||||
searchedBroker = null;
|
||||
searchError = null;
|
||||
|
||||
if (!companyStore.activeCompany) return;
|
||||
|
||||
if (!silent) {
|
||||
listLoading = true;
|
||||
}
|
||||
|
||||
listError = null;
|
||||
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 {
|
||||
const response = await customsBrokersApi.list(companyStore.activeCompany.id.toString());
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
if (response.data) {
|
||||
if (Array.isArray(response.data)) {
|
||||
brokersList = response.data;
|
||||
} else if (response.data.items && Array.isArray(response.data.items)) {
|
||||
brokersList = response.data.items;
|
||||
} else {
|
||||
console.error("⚠️ Estructura de datos inesperada:", response.data);
|
||||
brokersList = [];
|
||||
}
|
||||
}
|
||||
await customsBrokersApi.delete(selectedItem.broker_key, companyStore.activeCompany.id.toString());
|
||||
toast.success('Agente eliminado');
|
||||
selectedItem = null;
|
||||
loadItems();
|
||||
} catch (e) {
|
||||
listError = 'Error recargando datos';
|
||||
console.error('📊 [CustomsBrokers] Error reloading:', e);
|
||||
} finally {
|
||||
listLoading = false;
|
||||
toast.error('Error al eliminar');
|
||||
}
|
||||
}
|
||||
|
||||
function handleCreateClick() {
|
||||
showCreateDialog = true;
|
||||
}
|
||||
|
||||
function handleSuccess() {
|
||||
reloadData(false);
|
||||
}
|
||||
|
||||
const columns = createColumns(handleSuccess);
|
||||
const brokers = $derived(searchedBroker ? [searchedBroker] : brokersList);
|
||||
</script>
|
||||
|
||||
<div class="space-y-6">
|
||||
<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 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>
|
||||
|
||||
<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 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="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>
|
||||
|
||||
<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 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>
|
||||
{/if}
|
||||
|
||||
<div class={listLoading ? 'opacity-50 transition-opacity duration-300' : 'transition-opacity duration-300'}>
|
||||
<DataTable
|
||||
data={brokers}
|
||||
{columns}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
</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>
|
||||
|
||||
<div class="flex-1 overflow-auto bg-card">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="bg-muted text-muted-foreground sticky top-0 z-10 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>
|
||||
|
||||
<!-- 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 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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CreateDialog bind:open={showCreateDialog} onSuccess={handleSuccess} />
|
||||
<!-- 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>
|
||||
@@ -9,7 +9,11 @@
|
||||
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';
|
||||
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';
|
||||
|
||||
// --- 1. LÓGICA DE IDENTIFICACIÓN ---
|
||||
let routeId = $derived($page.params.id);
|
||||
@@ -58,8 +62,9 @@
|
||||
loading = true;
|
||||
try {
|
||||
const res = await customsBrokersApi.get(key, cId);
|
||||
if (res.data) {
|
||||
const d = res.data;
|
||||
const d = (res as any).data || res; // Handle wrapper or direct
|
||||
|
||||
if (d && !d.error) {
|
||||
// Mapeo exhaustivo para asegurar reactividad
|
||||
formData = {
|
||||
broker_key: d.broker_key || "",
|
||||
@@ -82,11 +87,13 @@
|
||||
company_id: cId
|
||||
};
|
||||
dataLoaded = true;
|
||||
} else if (res.error) {
|
||||
error = res.error;
|
||||
} else if (d.error) {
|
||||
error = d.error;
|
||||
toast.error(error);
|
||||
}
|
||||
} catch (e) {
|
||||
} catch (e: any) {
|
||||
error = "Error al conectar con el servidor";
|
||||
toast.error(error);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
@@ -94,182 +101,227 @@
|
||||
|
||||
// --- 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() || "";
|
||||
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.error) throw new Error(res.error);
|
||||
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');
|
||||
}
|
||||
</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.'}
|
||||
<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>
|
||||
|
||||
{#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}
|
||||
<Separator />
|
||||
|
||||
<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(); }}>
|
||||
<!-- Main Content -->
|
||||
<div class="pb-48">
|
||||
<form onsubmit={(e) => { e.preventDefault(); handleSave(); }}>
|
||||
<Tabs.Root bind:value={activeTab}>
|
||||
|
||||
{#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} />
|
||||
<!-- 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 class="required">Patente</Label>
|
||||
<Input bind:value={formData.license} placeholder="Ej. 3421" disabled={loading} />
|
||||
<Label>Nombre / Razón Social</Label>
|
||||
<Input bind:value={formData.name} placeholder="Nombre oficial" 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 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 class="grid gap-2">
|
||||
<Label>CURP</Label>
|
||||
<Input bind:value={formData.personal_id} placeholder="CURP si aplica" disabled={loading} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
</Tabs.Content>
|
||||
|
||||
{: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} />
|
||||
<!-- 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>
|
||||
<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>
|
||||
|
||||
<Separator />
|
||||
|
||||
{: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 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 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}
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
</Tabs.Content>
|
||||
|
||||
<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>
|
||||
<!-- 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>
|
||||
|
||||
<style>
|
||||
:global(.required::after) {
|
||||
content: " *";
|
||||
color: hsl(var(--destructive));
|
||||
font-weight: bold;
|
||||
}
|
||||
</style>
|
||||
<!-- 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>
|
||||
|
||||
<!-- 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