Merge pull request 'feature/invoice-labels' (#122) from feature/invoice-labels into development

Reviewed-on: ADUANASOFT/anexo76#122
This commit is contained in:
2026-02-10 14:13:10 +00:00
21 changed files with 1378 additions and 1120 deletions

View File

@@ -1,6 +1,6 @@
from typing import Optional
from pydantic import BaseModel
from pydantic import BaseModel, Field
class CustomsBrokerBaseDTO(BaseModel):
@@ -19,7 +19,7 @@ class CustomsBrokerBaseDTO(BaseModel):
tax_id: Optional[str] = None
personal_id: Optional[str] = None
position: Optional[str] = None
license: Optional[str] = None
license: Optional[str] = Field(None, max_length=4, pattern=r"^\d*$")
company: Optional[str] = None
contact: Optional[str] = None
@@ -27,7 +27,7 @@ class CustomsBrokerBaseDTO(BaseModel):
class CustomsBrokerCreateDTO(CustomsBrokerBaseDTO):
"""Schema for creating a new CustomsBroker"""
broker_key: str
broker_key: str = Field(..., max_length=5, pattern=r"^[a-zA-Z0-9]+$", description="Clave única del agente aduanal (máx 5 caracteres)")
class CustomsBrokerUpdateDTO(CustomsBrokerBaseDTO):
@@ -51,7 +51,7 @@ class CustomsBrokerResponseDTO(CustomsBrokerBaseDTO):
# Legacy DTO for backwards compatibility (if needed elsewhere)
class CustomsBrokerDTO(BaseModel):
type: Optional[str] = None
broker_key: str
broker_key: str = Field(..., max_length=5, pattern=r"^[a-zA-Z0-9]+$")
name: Optional[str] = None
address: Optional[str] = None
postal_code: Optional[str] = None
@@ -64,7 +64,7 @@ class CustomsBrokerDTO(BaseModel):
tax_id: Optional[str] = None
personal_id: Optional[str] = None
position: Optional[str] = None
license: Optional[str] = None
license: Optional[str] = Field(None, max_length=4, pattern=r"^\d*$")
company: Optional[str] = None
contact: Optional[str] = None
tenant_id: str
@@ -100,13 +100,13 @@ class CustomsBrokerVUCreateDTO(BaseModel):
class CustomsBrokerPersonnelDTO(BaseModel):
broker_key: str
broker_key: str = Field(..., max_length=5, pattern=r"^[a-zA-Z0-9]+$")
line: int
name: Optional[str]
tax_id: Optional[str]
personal_id: Optional[str]
position: Optional[str]
license: Optional[str]
license: Optional[str] = Field(None, max_length=4, pattern=r"^\d*$")
first_name: Optional[str]
last_name: Optional[str]
middle_name: Optional[str]

View File

@@ -117,7 +117,7 @@ export const customsBrokersApi = {
* Elimina un agente aduanal
*/
delete: (brokerKey: string, companyId: string) => {
return api.delete<CustomsBroker>(`/v1/a76/customs-brokers/${brokerKey}/?company_id=${companyId}`);
return api.delete<CustomsBroker>(`/v1/a76/customs-brokers/${brokerKey}?company_id=${companyId}`);
},

View File

@@ -57,7 +57,7 @@ class PortsApi {
const queryParams = new URLSearchParams({
company_id: companyId.toString()
});
return api.get<Port>(`${this.baseUrl}/${id}/?${queryParams.toString()}`);
return api.get<Port>(`${this.baseUrl}/${id}?${queryParams.toString()}`);
}
async create(data: PortCreate, companyId: string | number): Promise<ApiResponse<Port>> {
@@ -78,7 +78,7 @@ class PortsApi {
const queryParams = new URLSearchParams({
company_id: companyId.toString()
});
return api.delete(`${this.baseUrl}/${id}/?${queryParams.toString()}`);
return api.delete(`${this.baseUrl}/${id}?${queryParams.toString()}`);
}
}

View File

@@ -1,221 +1,310 @@
<script lang="ts">
import { Button } from "$lib/components/ui/button";
import * as Dialog from "$lib/components/ui/dialog";
import { Input } from "$lib/components/ui/input";
import { Label } from "$lib/components/ui/label";
import { Separator } from "$lib/components/ui/separator";
import { Loader2 } from "lucide-svelte";
import type { CreateCustomsBrokerData, CustomsBroker } from "$lib/api/dashboard/a76/customs-brokers"; // Ajusta la ruta
import { toast } from "svelte-sonner";
import { Button } from '$lib/components/ui/button';
import * as Dialog from '$lib/components/ui/dialog';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import { Separator } from '$lib/components/ui/separator';
import { Loader2 } from 'lucide-svelte';
import type {
CreateCustomsBrokerData,
CustomsBroker
} from '$lib/api/dashboard/a76/customs-brokers'; // Ajusta la ruta
import { toast } from 'svelte-sonner';
// --- Props ---
export let open = false;
export let mode: "create" | "edit" = "create";
export let initialData: CustomsBroker | null = null;
export let companyId: string; // Necesario según tu API
// --- Props ---
export let open = false;
export let mode: 'create' | 'edit' = 'create';
export let initialData: CustomsBroker | null = null;
export let companyId: string; // Necesario según tu API
// La función onSave ahora devuelve una promesa para manejar el loading aquí
export let onSave: (data: CreateCustomsBrokerData) => Promise<void>;
// La función onSave ahora devuelve una promesa para manejar el loading aquí
export let onSave: (data: CreateCustomsBrokerData) => Promise<void>;
// --- Estado ---
let loading = false;
// --- Estado ---
let loading = false;
// Estado del formulario
let formData: CreateCustomsBrokerData = {
broker_key: "",
license: "",
name: "",
tax_id: "",
email: "",
phone: "",
fax: "",
contact: "",
address: "",
postal_code: "",
city: "",
state: "",
country: "",
tenant_id: "", // Se llenará en el submit o por defecto
company_id: ""
};
// Estado del formulario
let formData: CreateCustomsBrokerData = {
broker_key: '',
license: '',
name: '',
tax_id: '',
email: '',
phone: '',
fax: '',
contact: '',
address: '',
postal_code: '',
city: '',
state: '',
country: '',
tenant_id: '', // Se llenará en el submit o por defecto
company_id: ''
};
// --- Reactividad ---
$: if (open) {
if (mode === "edit" && initialData) {
// Cargar datos existentes
formData = {
...initialData,
// Aseguramos que no sean null/undefined para los inputs
name: initialData.name || "",
tax_id: initialData.tax_id || "",
email: initialData.email || "",
phone: initialData.phone || "",
fax: initialData.fax || "",
contact: initialData.contact || "",
address: initialData.address || "",
postal_code: initialData.postal_code || "",
city: initialData.city || "",
state: initialData.state || "",
country: initialData.country || "",
license: initialData.license || ""
};
} else {
// Reset para crear
formData = {
broker_key: "",
license: "",
name: "",
tax_id: "",
email: "",
phone: "",
fax: "",
contact: "",
address: "",
postal_code: "",
city: "",
state: "",
country: "MEX", // Valor por defecto sugerido
tenant_id: "default", // Ajustar según lógica de tu app
company_id: companyId
};
}
}
let brokerKeyError = false;
let licenseError = false;
let brokerKeyTimeout: ReturnType<typeof setTimeout>;
let licenseTimeout: ReturnType<typeof setTimeout>;
// --- Handlers ---
async function handleSubmit() {
try {
loading = true;
// --- Reactividad ---
$: if (open) {
if (mode === 'edit' && initialData) {
// Cargar datos existentes
formData = {
...initialData,
// Aseguramos que no sean null/undefined para los inputs
name: initialData.name || '',
tax_id: initialData.tax_id || '',
email: initialData.email || '',
phone: initialData.phone || '',
fax: initialData.fax || '',
contact: initialData.contact || '',
address: initialData.address || '',
postal_code: initialData.postal_code || '',
city: initialData.city || '',
state: initialData.state || '',
country: initialData.country || '',
license: initialData.license || ''
};
} else {
// Reset para crear
formData = {
broker_key: '',
license: '',
name: '',
tax_id: '',
email: '',
phone: '',
fax: '',
contact: '',
address: '',
postal_code: '',
city: '',
state: '',
country: 'MEX', // Valor por defecto sugerido
tenant_id: 'default', // Ajustar según lógica de tu app
company_id: companyId
};
}
}
// Validaciones básicas
if (!formData.broker_key) {
toast.error("La Clave del Agente es obligatoria");
loading = false;
return;
}
if (!formData.license) {
toast.error("La Patente es obligatoria");
loading = false;
return;
}
// --- Handlers ---
async function handleSubmit() {
try {
loading = true;
// Inyectar company_id si no viene
const payload = { ...formData, company_id: companyId };
// Validaciones básicas
if (!formData.broker_key) {
toast.error('La Clave del Agente es obligatoria');
loading = false;
return;
}
if (!formData.license) {
toast.error('La Patente es obligatoria');
loading = false;
return;
}
await onSave(payload);
open = false;
toast.success(mode === 'create' ? "Agente creado correctamente" : "Agente actualizado correctamente");
} catch (error) {
console.error(error);
toast.error("Error al guardar el agente aduanal");
} finally {
loading = false;
}
}
if (formData.broker_key.length > 5) {
toast.error('La Clave del Agente no debe superar los 5 caracteres');
loading = false;
return;
}
if (formData.license && formData.license.length > 4) {
toast.error('La Patente no debe superar los 4 dígitos');
loading = false;
return;
}
// Inyectar company_id si no viene
const payload = { ...formData, company_id: companyId };
await onSave(payload);
open = false;
toast.success(
mode === 'create' ? 'Agente creado correctamente' : 'Agente actualizado correctamente'
);
} catch (error) {
console.error(error);
toast.error('Error al guardar el agente aduanal');
} finally {
loading = false;
}
}
</script>
<Dialog.Root bind:open>
<Dialog.Content class="sm:max-w-[700px] max-h-[90vh] overflow-y-auto">
<Dialog.Header>
<Dialog.Title>
{mode === "create" ? "Nuevo Agente Aduanal" : "Editar Agente Aduanal"}
</Dialog.Title>
<Dialog.Description>
Ingresa los datos generales del agente. La configuración de VU y Personal se gestiona aparte.
</Dialog.Description>
</Dialog.Header>
<Dialog.Content class="sm:max-w-[700px] max-h-[90vh] overflow-y-auto">
<Dialog.Header>
<Dialog.Title>
{mode === 'create' ? 'Nuevo Agente Aduanal' : 'Editar Agente Aduanal'}
</Dialog.Title>
<Dialog.Description>
Ingresa los datos generales del agente. La configuración de VU y Personal se gestiona
aparte.
</Dialog.Description>
</Dialog.Header>
<div class="grid gap-6 py-4">
<div class="space-y-4">
<h4 class="text-sm font-medium leading-none text-muted-foreground">Identificación</h4>
<div class="grid grid-cols-2 gap-4">
<div class="space-y-2">
<Label for="broker_key">Clave Agente *</Label>
<Input id="broker_key" bind:value={formData.broker_key} placeholder="Ej. 550" disabled={mode === 'edit' || loading} />
</div>
<div class="space-y-2">
<Label for="license">Patente *</Label>
<Input id="license" bind:value={formData.license} placeholder="Ej. 3421" disabled={loading} />
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div class="space-y-2 col-span-2">
<Label for="name">Nombre / Razón Social</Label>
<Input id="name" bind:value={formData.name} placeholder="Nombre del Agente o Agencia" disabled={loading} />
</div>
<div class="space-y-2">
<Label for="tax_id">RFC</Label>
<Input id="tax_id" bind:value={formData.tax_id} placeholder="RFC de la agencia" disabled={loading} />
</div>
<div class="space-y-2">
<Label for="contact">Nombre Contacto</Label>
<Input id="contact" bind:value={formData.contact} placeholder="Persona de contacto" disabled={loading} />
</div>
</div>
</div>
<div class="grid gap-6 py-4">
<div class="space-y-4">
<h4 class="text-sm font-medium leading-none text-muted-foreground">Identificación</h4>
<div class="grid grid-cols-2 gap-4">
<div class="space-y-2">
<Label for="broker_key">Clave Agente *</Label>
<Input
id="broker_key"
value={formData.broker_key}
oninput={(e) => {
const val = e.currentTarget.value.toUpperCase();
if (val.length > 5) {
brokerKeyError = true;
formData.broker_key = val.slice(0, 5);
e.currentTarget.value = formData.broker_key;
<Separator />
clearTimeout(brokerKeyTimeout);
brokerKeyTimeout = setTimeout(() => {
brokerKeyError = false;
}, 3000);
} else {
brokerKeyError = false;
formData.broker_key = val;
}
}}
placeholder="Ej. 550"
maxlength="6"
class={brokerKeyError ? 'border-red-500 focus-visible:ring-red-500' : ''}
disabled={mode === 'edit' || loading}
/>
{#if brokerKeyError}
<p class="text-[0.8rem] font-medium text-destructive">
La clave no debe superar los 5 caracteres
</p>
{/if}
</div>
<div class="space-y-2">
<Label for="license">Patente *</Label>
<Input
id="license"
value={formData.license}
oninput={(e) => {
const val = e.currentTarget.value.replace(/\D/g, '');
if (val.length > 4) {
licenseError = true;
formData.license = val.slice(0, 4);
e.currentTarget.value = formData.license;
<div class="space-y-4">
<h4 class="text-sm font-medium leading-none text-muted-foreground">Contacto</h4>
<div class="grid grid-cols-3 gap-4">
<div class="space-y-2 col-span-1">
<Label for="phone">Teléfono</Label>
<Input id="phone" bind:value={formData.phone} disabled={loading} />
</div>
<div class="space-y-2 col-span-2">
<Label for="email">Correo Electrónico</Label>
<Input id="email" type="email" bind:value={formData.email} disabled={loading} />
</div>
</div>
</div>
clearTimeout(licenseTimeout);
licenseTimeout = setTimeout(() => {
licenseError = false;
}, 3000);
} else {
licenseError = false;
formData.license = val;
}
}}
placeholder="Ej. 3421"
maxlength="5"
class={licenseError ? 'border-red-500 focus-visible:ring-red-500' : ''}
disabled={loading}
/>
{#if licenseError}
<p class="text-[0.8rem] font-medium text-destructive">
La patente no debe superar los 4 dígitos
</p>
{/if}
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div class="space-y-2 col-span-2">
<Label for="name">Nombre / Razón Social</Label>
<Input
id="name"
bind:value={formData.name}
placeholder="Nombre del Agente o Agencia"
disabled={loading}
/>
</div>
<div class="space-y-2">
<Label for="tax_id">RFC</Label>
<Input
id="tax_id"
bind:value={formData.tax_id}
placeholder="RFC de la agencia"
disabled={loading}
/>
</div>
<div class="space-y-2">
<Label for="contact">Nombre Contacto</Label>
<Input
id="contact"
bind:value={formData.contact}
placeholder="Persona de contacto"
disabled={loading}
/>
</div>
</div>
</div>
<Separator />
<Separator />
<div class="space-y-4">
<h4 class="text-sm font-medium leading-none text-muted-foreground">Dirección Fiscal</h4>
<div class="space-y-2">
<Label for="address">Calle y Número</Label>
<Input id="address" bind:value={formData.address} disabled={loading} />
</div>
<div class="space-y-4">
<h4 class="text-sm font-medium leading-none text-muted-foreground">Contacto</h4>
<div class="grid grid-cols-3 gap-4">
<div class="space-y-2 col-span-1">
<Label for="phone">Teléfono</Label>
<Input id="phone" bind:value={formData.phone} disabled={loading} />
</div>
<div class="space-y-2 col-span-2">
<Label for="email">Correo Electrónico</Label>
<Input id="email" type="email" bind:value={formData.email} disabled={loading} />
</div>
</div>
</div>
<div class="grid grid-cols-4 gap-4">
<div class="space-y-2">
<Label for="postal_code">C.P.</Label>
<Input id="postal_code" bind:value={formData.postal_code} disabled={loading} />
</div>
<div class="space-y-2 col-span-2">
<Label for="city">Ciudad</Label>
<Input id="city" bind:value={formData.city} disabled={loading} />
</div>
<div class="space-y-2">
<Label for="state">Estado</Label>
<Input id="state" bind:value={formData.state} disabled={loading} />
</div>
</div>
<div class="grid grid-cols-4 gap-4">
<div class="space-y-2">
<Label for="country">País</Label>
<Input id="country" bind:value={formData.country} disabled={loading} />
</div>
</div>
</div>
<Separator />
</div>
<div class="space-y-4">
<h4 class="text-sm font-medium leading-none text-muted-foreground">Dirección Fiscal</h4>
<Dialog.Footer>
<Button variant="outline" onclick={() => (open = false)} disabled={loading}>
Cancelar
</Button>
<Button onclick={handleSubmit} disabled={loading}>
{#if loading}
<Loader2 class="mr-2 h-4 w-4 animate-spin" />
Guardando
{:else}
Guardar Agente
{/if}
</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
<div class="space-y-2">
<Label for="address">Calle y Número</Label>
<Input id="address" bind:value={formData.address} disabled={loading} />
</div>
<div class="grid grid-cols-4 gap-4">
<div class="space-y-2">
<Label for="postal_code">C.P.</Label>
<Input id="postal_code" bind:value={formData.postal_code} disabled={loading} />
</div>
<div class="space-y-2 col-span-2">
<Label for="city">Ciudad</Label>
<Input id="city" bind:value={formData.city} disabled={loading} />
</div>
<div class="space-y-2">
<Label for="state">Estado</Label>
<Input id="state" bind:value={formData.state} disabled={loading} />
</div>
</div>
<div class="grid grid-cols-4 gap-4">
<div class="space-y-2">
<Label for="country">País</Label>
<Input id="country" bind:value={formData.country} disabled={loading} />
</div>
</div>
</div>
</div>
<Dialog.Footer>
<Button variant="outline" onclick={() => (open = false)} disabled={loading}>Cancelar</Button>
<Button onclick={handleSubmit} disabled={loading}>
{#if loading}
<Loader2 class="mr-2 h-4 w-4 animate-spin" />
Guardando
{:else}
Guardar Agente
{/if}
</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>

View File

@@ -1,18 +1,17 @@
<script lang="ts" generics="TData, TValue">
import {
type ColumnDef,
getCoreRowModel
} from "@tanstack/table-core";
import { createSvelteTable, FlexRender } from "$lib/components/ui/data-table/index.js";
import * as Table from "$lib/components/ui/table/index.js";
import { type ColumnDef, getCoreRowModel } from '@tanstack/table-core';
import { createSvelteTable, FlexRender } from '$lib/components/ui/data-table/index.js';
import * as Table from '$lib/components/ui/table/index.js';
type DataTableProps<TData, TValue> = {
columns: ColumnDef<TData, TValue>[];
data: TData[];
onRowClick?: (row: TData) => void;
selectedId?: string | number | null;
idField?: keyof TData;
};
let { data, columns }: DataTableProps<TData, TValue> = $props();
let { data, columns, onRowClick, selectedId, idField }: DataTableProps<TData, TValue> = $props();
const table = $derived(
createSvelteTable({
@@ -45,13 +44,17 @@
<Table.Body>
{#each table.getRowModel().rows as row (row.id)}
<Table.Row>
<Table.Row
onclick={() => onRowClick?.(row.original)}
class="cursor-pointer transition-colors hover:bg-muted/50 {selectedId &&
idField &&
row.original[idField] === selectedId
? 'bg-muted'
: ''}"
>
{#each row.getVisibleCells() as cell (cell.id)}
<Table.Cell>
<FlexRender
content={cell.column.columnDef.cell}
context={cell.getContext()}
/>
<FlexRender content={cell.column.columnDef.cell} context={cell.getContext()} />
</Table.Cell>
{/each}
</Table.Row>

View File

@@ -1,10 +1,14 @@
<script lang="ts">
import { Button } from "$lib/components/ui/button";
import * as Dialog from "$lib/components/ui/dialog";
import { Input } from "$lib/components/ui/input";
import { Label } from "$lib/components/ui/label";
import { customsBrokersApi, type CreateCustomsBrokerData, type CustomsBroker } from "$lib/api/dashboard/a76/customs-brokers";
import { companyStore } from "$lib/stores/company.svelte";
import { Button } from '$lib/components/ui/button';
import * as Dialog from '$lib/components/ui/dialog';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import {
customsBrokersApi,
type CreateCustomsBrokerData,
type CustomsBroker
} from '$lib/api/dashboard/a76/customs-brokers';
import { companyStore } from '$lib/stores/company.svelte';
let {
open = $bindable(false),
@@ -17,56 +21,58 @@
} = $props();
let formData = $state({
name: "",
type: "",
address: "",
postal_code: "",
city: "",
state: "",
phone: "",
fax: "",
email: "",
country: "",
tax_id: "",
personal_id: "",
position: "",
license: "",
company: "",
contact: ""
name: '',
type: '',
address: '',
postal_code: '',
city: '',
state: '',
phone: '',
fax: '',
email: '',
country: '',
tax_id: '',
personal_id: '',
position: '',
license: '',
company: '',
contact: ''
});
let loading = $state(false);
let error = $state<string | null>(null);
let licenseError = $state(false);
let licenseTimeout: ReturnType<typeof setTimeout>;
// Inicializar formulario cuando cambia el broker
$effect(() => {
if (open && broker) {
formData = {
name: broker.name || "",
type: broker.type || "",
address: broker.address || "",
postal_code: broker.postal_code || "",
city: broker.city || "",
state: broker.state || "",
phone: broker.phone || "",
fax: broker.fax || "",
email: broker.email || "",
country: broker.country || "",
tax_id: broker.tax_id || "",
personal_id: broker.personal_id || "",
position: broker.position || "",
license: broker.license || "",
company: broker.company || "",
contact: broker.contact || ""
name: broker.name || '',
type: broker.type || '',
address: broker.address || '',
postal_code: broker.postal_code || '',
city: broker.city || '',
state: broker.state || '',
phone: broker.phone || '',
fax: broker.fax || '',
email: broker.email || '',
country: broker.country || '',
tax_id: broker.tax_id || '',
personal_id: broker.personal_id || '',
position: broker.position || '',
license: broker.license || '',
company: broker.company || '',
contact: broker.contact || ''
};
}
});
async function handleSubmit(e: Event) {
e.preventDefault();
if (!companyStore.activeCompany) {
error = "No hay compañía seleccionada";
error = 'No hay compañía seleccionada';
return;
}
@@ -116,8 +122,8 @@
onSuccess();
}
} catch (e) {
error = e instanceof Error ? e.message : "Error al guardar";
console.error("Error updating:", e);
error = e instanceof Error ? e.message : 'Error al guardar';
console.error('Error updating:', e);
} finally {
loading = false;
}
@@ -136,13 +142,17 @@
<Dialog.Header>
<Dialog.Title>Editar Agente Aduanal</Dialog.Title>
<Dialog.Description>
Modifica los datos del agente aduanal <span class="font-mono font-semibold">{broker?.broker_key}</span>.
Modifica los datos del agente aduanal <span class="font-mono font-semibold"
>{broker?.broker_key}</span
>.
</Dialog.Description>
</Dialog.Header>
<form onsubmit={handleSubmit} class="space-y-6">
{#if error}
<div class="rounded-lg border border-destructive bg-destructive/10 p-3 text-sm text-destructive">
<div
class="rounded-lg border border-destructive bg-destructive/10 p-3 text-sm text-destructive"
>
{error}
</div>
{/if}
@@ -150,7 +160,7 @@
<!-- Información básica -->
<div class="space-y-4">
<h3 class="text-sm font-semibold">Información Básica</h3>
<div class="grid grid-cols-2 gap-4">
<div class="space-y-2">
<Label for="edit-broker_key">Clave</Label>
@@ -158,7 +168,7 @@
id="edit-broker_key"
value={broker?.broker_key}
disabled
class="bg-muted"
class="bg-muted {broker?.broker_key?.length > 5 ? 'border-red-500' : ''}"
/>
</div>
@@ -190,11 +200,33 @@
<Label for="edit-license">Patente</Label>
<Input
id="edit-license"
bind:value={formData.license}
value={formData.license}
oninput={(e) => {
const val = e.currentTarget.value.replace(/\D/g, '');
if (val.length > 4) {
licenseError = true;
formData.license = val.slice(0, 4);
e.currentTarget.value = formData.license;
clearTimeout(licenseTimeout);
licenseTimeout = setTimeout(() => {
licenseError = false;
}, 3000);
} else {
licenseError = false;
formData.license = val;
}
}}
placeholder="Número de patente"
maxlength={4}
maxlength="5"
class={licenseError ? 'border-red-500 focus-visible:ring-red-500' : ''}
disabled={loading}
/>
{#if licenseError}
<p class="text-[0.8rem] font-medium text-destructive">
La patente no debe superar los 4 dígitos
</p>
{/if}
</div>
<div class="space-y-2">
@@ -213,7 +245,7 @@
<!-- Información de contacto -->
<div class="space-y-4">
<h3 class="text-sm font-semibold">Información de Contacto</h3>
<div class="grid grid-cols-2 gap-4">
<div class="space-y-2">
<Label for="edit-phone">Teléfono</Label>
@@ -265,7 +297,7 @@
<!-- Dirección -->
<div class="space-y-4">
<h3 class="text-sm font-semibold">Dirección</h3>
<div class="space-y-2">
<Label for="edit-address">Dirección</Label>
<Input
@@ -327,7 +359,7 @@
<!-- Información fiscal -->
<div class="space-y-4">
<h3 class="text-sm font-semibold">Información Fiscal</h3>
<div class="grid grid-cols-2 gap-4">
<div class="space-y-2">
<Label for="edit-tax_id">RFC</Label>
@@ -371,7 +403,9 @@
<Button type="submit" disabled={loading}>
{#if loading}
<div class="flex items-center gap-2">
<div class="h-4 w-4 animate-spin rounded-full border-2 border-primary-foreground border-t-transparent"></div>
<div
class="h-4 w-4 animate-spin rounded-full border-2 border-primary-foreground border-t-transparent"
></div>
Guardando...
</div>
{:else}

View File

@@ -6,6 +6,7 @@
import { Plus, Minus, Search, Loader2, Ghost } from 'lucide-svelte';
import type { Invoice } from '$lib/api/dashboard/a76/invoices';
import { fly } from 'svelte/transition';
import { getInvoiceTypeColor } from '$lib/utils';
let {
title = '',
@@ -101,7 +102,8 @@
)}
</Table.Cell>
<Table.Cell class="text-xs"
><Badge variant="outline" class="bg-background">{invoice.invoice_type}</Badge
><Badge variant="outline" class={getInvoiceTypeColor(invoice.invoice_type)}
>{invoice.invoice_type}</Badge
></Table.Cell
>
<Table.Cell>

View File

@@ -3,6 +3,7 @@ import { renderComponent, renderSnippet } from "$lib/components/ui/data-table/in
import { createRawSnippet } from "svelte";
import DataTableActions from "./data-table-actions.svelte";
import type { Invoice } from '$lib/api/dashboard/a76/invoices';
import { getInvoiceTypeColor } from "$lib/utils";
function formatDate(date?: string | null): string {
if (!date) return '-';
@@ -70,15 +71,18 @@ export function createColumns(
header: "Tipo Factura",
cell: ({ row }) => {
const invoiceType = row.original.invoice_type;
const colorClass = getInvoiceTypeColor(invoiceType);
const typeSnippet = createRawSnippet<[{ type?: string | null }]>((getType) => {
const { type } = getType();
const typeSnippet = createRawSnippet<[{ type?: string | null; colorClass: string }]>((getProps) => {
const { type, colorClass } = getProps();
return {
render: () =>
`<div class="text-sm font-medium">${type || '-'}</div>`
`<span class="inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium ${colorClass}">
${type || '-'}
</span>`
};
});
return renderSnippet(typeSnippet, { type: invoiceType });
return renderSnippet(typeSnippet, { type: invoiceType, colorClass });
}
},
{

View File

@@ -4,6 +4,7 @@
import * as Tabs from '$lib/components/ui/tabs';
import { Badge } from '$lib/components/ui/badge';
import { Button } from '$lib/components/ui/button';
import { getInvoiceTypeColor } from '$lib/utils';
interface Props {
invoice: Invoice;
@@ -32,13 +33,17 @@
}
</script>
<Dialog.Root {open} onOpenChange={(v) => { open = v; if (!v) onClose(); }}>
<Dialog.Root
{open}
onOpenChange={(v) => {
open = v;
if (!v) onClose();
}}
>
<Dialog.Content class="max-w-5xl max-h-[90vh] overflow-y-auto">
<Dialog.Header>
<Dialog.Title>Detalles de Factura #{invoice?.id}</Dialog.Title>
<Dialog.Description>
Información completa de la factura
</Dialog.Description>
<Dialog.Description>Información completa de la factura</Dialog.Description>
</Dialog.Header>
{#if invoice}
@@ -74,7 +79,15 @@
<div>
<p class="text-sm font-medium text-muted-foreground">Tipo de Factura</p>
<p class="text-base">{invoice.invoice_type || '-'}</p>
<p class="text-base">
{#if invoice.invoice_type}
<Badge variant="outline" class={getInvoiceTypeColor(invoice.invoice_type)}
>{invoice.invoice_type}</Badge
>
{:else}
-
{/if}
</p>
</div>
<div>
@@ -102,10 +115,11 @@
<p class="text-base">{invoice.traffic_light_status || '-'}</p>
</div>
<div>
<p class="text-sm font-medium text-muted-foreground">CFDI UUID</p>
<p class="text-xs break-all">{invoice.cfdi_uuid || '-'}</p>
</div> <div>
<div>
<p class="text-sm font-medium text-muted-foreground">CFDI UUID</p>
<p class="text-xs break-all">{invoice.cfdi_uuid || '-'}</p>
</div>
<div>
<p class="text-sm font-medium text-muted-foreground">Actualizado</p>
<p class="text-base">{invoice.is_updated ? 'Sí' : 'No'}</p>
</div>
@@ -196,11 +210,11 @@
<p class="text-base">{invoice.compliance_mx.edocument || '-'}</p>
</div>
<div class="col-span-2">
<p class="text-sm font-medium text-muted-foreground">Firma Electrónica</p>
<p class="text-xs break-all">{invoice.compliance_mx.electronic_signature || '-'}</p>
<div class="col-span-2">
<p class="text-sm font-medium text-muted-foreground">Firma Electrónica</p>
<p class="text-xs break-all">{invoice.compliance_mx.electronic_signature || '-'}</p>
</div>
</div>
</div>
{:else}
<p class="text-muted-foreground">No hay información de cumplimiento disponible.</p>
{/if}
@@ -362,7 +376,9 @@
</div>
<div>
<p class="text-sm font-medium text-muted-foreground">Descripción de Colores</p>
<p class="text-sm font-medium text-muted-foreground">
Descripción de Colores
</p>
<p class="text-base">{detail.colors_description || '-'}</p>
</div>
@@ -399,7 +415,9 @@
</div>
<div>
<p class="text-sm font-medium text-muted-foreground">Fecha de Cobranza</p>
<p class="text-sm font-medium text-muted-foreground">
Fecha de Cobranza
</p>
<p class="text-base">{formatDate(collection.collection_date)}</p>
</div>
@@ -427,9 +445,7 @@
{/if}
<Dialog.Footer>
<Button variant="outline" onclick={() => (open = false)}>
Cerrar
</Button>
<Button variant="outline" onclick={() => (open = false)}>Cerrar</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>

View File

@@ -83,7 +83,7 @@
</script>
<Dialog.Root bind:open>
<Dialog.Content class="!max-w-[70vw] w-[70vw] max-h-[90vh] p-0 flex flex-col">
<Dialog.Content class="max-w-4xl w-full max-h-[85vh] p-0 flex flex-col">
<Dialog.Header class="px-6 py-4 border-b">
<Dialog.Title class="text-lg font-semibold">CATALOGO DE PAISES</Dialog.Title>
</Dialog.Header>
@@ -91,11 +91,7 @@
<div class="px-6 py-3 border-b bg-zinc-50 dark:bg-zinc-900">
<div class="flex items-center gap-2">
<Search class="w-4 h-4 text-zinc-400" />
<Input
bind:value={searchTerm}
placeholder="Buscando..."
class="flex-1 h-9"
/>
<Input bind:value={searchTerm} placeholder="Buscando..." class="flex-1 h-9" />
</div>
</div>
@@ -113,9 +109,7 @@
<table class="w-full text-sm">
<thead class="bg-zinc-900 dark:bg-zinc-800 text-white">
<tr>
<th class="px-3 py-2 text-left font-semibold border-r border-zinc-700"
>Clave M3</th
>
<th class="px-3 py-2 text-left font-semibold border-r border-zinc-700">Clave M3</th>
<th class="px-3 py-2 text-left font-semibold border-r border-zinc-700"
>Clave Mexicana</th
>
@@ -134,11 +128,11 @@
class="border-b hover:bg-zinc-100 dark:hover:bg-zinc-800 cursor-pointer transition-colors"
onclick={() => handleSelect(country)}
>
<td class="px-3 py-2 border-r">{country.m3_key || ''}</td>
<td class="px-3 py-2 border-r">{country.mex_key || ''}</td>
<td class="px-3 py-2 border-r">{country.description_es || ''}</td>
<td class="px-3 py-2 border-r text-center">{country.ame_key || ''}</td>
<td class="px-3 py-2">{country.description_en || ''}</td>
<td class="px-3 py-2 border-r">{country.m3_key || ''}</td>
<td class="px-3 py-2 border-r">{country.mex_key || ''}</td>
<td class="px-3 py-2 border-r">{country.description_es || ''}</td>
<td class="px-3 py-2 border-r text-center">{country.ame_key || ''}</td>
<td class="px-3 py-2">{country.description_en || ''}</td>
</tr>
{/each}
{#if filteredCountries.length === 0}
@@ -152,7 +146,9 @@
</table>
</div>
<div class="mt-4 flex items-center justify-between text-sm text-zinc-600 dark:text-zinc-400">
<div
class="mt-4 flex items-center justify-between text-sm text-zinc-600 dark:text-zinc-400"
>
<div class="flex items-center gap-4">
<button class="px-3 py-1 border rounded hover:bg-zinc-100 dark:hover:bg-zinc-800">
&lt;&lt;

View File

@@ -24,11 +24,12 @@
const filteredParts = $derived(
searchQuery
? parts.filter(p =>
p.part_number?.toLowerCase().includes(searchQuery.toLowerCase()) ||
p.description_spanish?.toLowerCase().includes(searchQuery.toLowerCase()) ||
p.description_english?.toLowerCase().includes(searchQuery.toLowerCase())
)
? parts.filter(
(p) =>
p.part_number?.toLowerCase().includes(searchQuery.toLowerCase()) ||
p.description_spanish?.toLowerCase().includes(searchQuery.toLowerCase()) ||
p.description_english?.toLowerCase().includes(searchQuery.toLowerCase())
)
: parts
);
@@ -52,15 +53,12 @@
isSearching = true;
try {
const response = await fetch(
`/api-sveltekit/parts?company_id=${activeCompanyId}&limit=100`,
{
method: 'GET',
headers: {
'Content-Type': 'application/json'
}
const response = await fetch(`/api-sveltekit/parts?company_id=${activeCompanyId}&limit=100`, {
method: 'GET',
headers: {
'Content-Type': 'application/json'
}
);
});
if (!response.ok) {
throw new Error('Error al buscar números de parte');
@@ -87,8 +85,9 @@
function handleScroll(e: Event) {
const target = e.target as HTMLDivElement;
const threshold = 100;
const scrolledToBottom = target.scrollHeight - target.scrollTop - target.clientHeight < threshold;
const scrolledToBottom =
target.scrollHeight - target.scrollTop - target.clientHeight < threshold;
if (scrolledToBottom && displayedParts.length < filteredParts.length) {
currentPage++;
loadMoreParts();
@@ -104,12 +103,10 @@
</script>
<Dialog.Root bind:open>
<Dialog.Content class="!max-w-[50vw] w-[50vw] max-h-[80vh] flex flex-col">
<Dialog.Content class="max-w-5xl w-full max-h-[90vh] p-0 flex flex-col">
<Dialog.Header>
<Dialog.Title>Seleccionar Número de Parte</Dialog.Title>
<Dialog.Description>
Busca y selecciona un número de parte para la partida
</Dialog.Description>
<Dialog.Description>Busca y selecciona un número de parte para la partida</Dialog.Description>
</Dialog.Header>
<div class="flex gap-2 mb-4">
@@ -148,15 +145,18 @@
</Table.Row>
{:else}
{#each displayedParts as part}
<Table.Row class="cursor-pointer hover:bg-muted/50" onclick={() => handleSelect(part)}>
<Table.Row
class="cursor-pointer hover:bg-muted/50"
onclick={() => handleSelect(part)}
>
<Table.Cell class="font-medium">{part.part_number}</Table.Cell>
<Table.Cell>{part.description_spanish || '-'}</Table.Cell>
<Table.Cell class="text-muted-foreground">{part.description_english || '-'}</Table.Cell>
<Table.Cell class="text-muted-foreground"
>{part.description_english || '-'}</Table.Cell
>
<Table.Cell class="text-muted-foreground">{part.part_class || '-'}</Table.Cell>
<Table.Cell>
<Button variant="ghost" size="sm" class="h-8">
Seleccionar
</Button>
<Button variant="ghost" size="sm" class="h-8">Seleccionar</Button>
</Table.Cell>
</Table.Row>
{/each}

View File

@@ -17,7 +17,7 @@
let loadingMore = $state(false);
let searchTerm = $state('');
let error = $state('');
// Pagination state
let currentPage = $state(1);
let totalPages = $state(1);
@@ -34,7 +34,7 @@
loadingMore = true;
}
error = '';
try {
const params = new URLSearchParams({
page: page.toString(),
@@ -45,11 +45,11 @@
const response = await fetch(`/api-sveltekit/tariff-fractions?${params}`, {
credentials: 'include'
});
if (response.ok) {
const data = await response.json();
console.log('Tariff fractions data received:', data);
if (data.items && Array.isArray(data.items)) {
if (append) {
fractions = [...fractions, ...data.items];
@@ -79,10 +79,10 @@
function handleScroll(e: Event) {
if (!scrollContainer || loading || loadingMore || !hasMore) return;
const target = e.target as HTMLDivElement;
const scrollBottom = target.scrollHeight - target.scrollTop - target.clientHeight;
// Load more when within 200px of bottom
if (scrollBottom < 200) {
loadFractions(currentPage + 1, true);
@@ -120,7 +120,7 @@
</script>
<Dialog.Root bind:open>
<Dialog.Content class="!max-w-[80vw] w-[80vw] max-h-[90vh] p-0 flex flex-col">
<Dialog.Content class="max-w-4xl w-full max-h-[80vh] flex flex-col px-6">
<Dialog.Header class="px-6 py-4 border-b">
<Dialog.Title class="text-lg font-semibold">FRACCIONES ARANCELARIAS</Dialog.Title>
</Dialog.Header>
@@ -139,11 +139,7 @@
</p>
</div>
<div
bind:this={scrollContainer}
onscroll={handleScroll}
class="flex-1 overflow-auto px-6 py-4"
>
<div bind:this={scrollContainer} onscroll={handleScroll} class="flex-1 overflow-auto px-6 py-4">
{#if loading}
<div class="flex items-center justify-center py-20">
<Loader2 class="w-8 h-8 animate-spin text-zinc-900 dark:text-zinc-100" />
@@ -157,18 +153,12 @@
<table class="w-full text-sm">
<thead class="bg-zinc-900 dark:bg-zinc-800 text-white sticky top-0">
<tr>
<th class="px-3 py-2 text-left font-semibold border-r border-zinc-700"
>Código</th
>
<th class="px-3 py-2 text-left font-semibold border-r border-zinc-700"
>Fracción</th
>
<th class="px-3 py-2 text-left font-semibold border-r border-zinc-700">Código</th>
<th class="px-3 py-2 text-left font-semibold border-r border-zinc-700">Fracción</th>
<th class="px-3 py-2 text-left font-semibold border-r border-zinc-700"
>Descripción</th
>
<th class="px-3 py-2 text-left font-semibold border-r border-zinc-700"
>NICO</th
>
<th class="px-3 py-2 text-left font-semibold border-r border-zinc-700">NICO</th>
<th class="px-3 py-2 text-left font-semibold">UMT</th>
</tr>
</thead>
@@ -204,9 +194,7 @@
{/if}
{#if !hasMore && fractions.length > 0}
<div class="text-center py-4 text-sm text-zinc-500">
Todos los resultados cargados
</div>
<div class="text-center py-4 text-sm text-zinc-500">Todos los resultados cargados</div>
{/if}
{/if}
</div>

View File

@@ -84,7 +84,7 @@
</script>
<Dialog.Root bind:open>
<Dialog.Content class="!max-w-[70vw] w-[70vw] max-h-[90vh] p-0 flex flex-col">
<Dialog.Content class="max-w-4xl w-full max-h-[90vh] p-0 flex flex-col">
<Dialog.Header class="px-6 py-4 border-b">
<Dialog.Title class="text-lg font-semibold">CATALOGOS DE UNIDADES DE MEDIDA</Dialog.Title>
</Dialog.Header>
@@ -92,11 +92,7 @@
<div class="px-6 py-3 border-b bg-zinc-50 dark:bg-zinc-900">
<div class="flex items-center gap-2">
<Search class="w-4 h-4 text-zinc-400" />
<Input
bind:value={searchTerm}
placeholder="Buscando..."
class="flex-1 h-9"
/>
<Input bind:value={searchTerm} placeholder="Buscando..." class="flex-1 h-9" />
</div>
</div>
@@ -114,9 +110,7 @@
<table class="w-full text-sm">
<thead class="bg-zinc-900 dark:bg-zinc-800 text-white">
<tr>
<th class="px-3 py-2 text-left font-semibold border-r border-zinc-700"
>U.M.</th
>
<th class="px-3 py-2 text-left font-semibold border-r border-zinc-700">U.M.</th>
<th class="px-3 py-2 text-left font-semibold border-r border-zinc-700"
>Descripción Español</th
>
@@ -157,7 +151,9 @@
</table>
</div>
<div class="mt-4 flex items-center justify-between text-sm text-zinc-600 dark:text-zinc-400">
<div
class="mt-4 flex items-center justify-between text-sm text-zinc-600 dark:text-zinc-400"
>
<div class="flex items-center gap-4">
<button class="px-3 py-1 border rounded hover:bg-zinc-100 dark:hover:bg-zinc-800">
&lt;&lt;

View File

@@ -36,6 +36,7 @@ export function createColumns(onSuccess?: () => void): ColumnDef<Port>[] {
},
{
id: 'actions',
header: 'Acciones',
cell: ({ row }) => {
return renderComponent(DataTableActions, {
item: row.original,

View File

@@ -1,10 +1,10 @@
<script lang="ts">
import { Button } from "$lib/components/ui/button";
import * as DropdownMenu from "$lib/components/ui/dropdown-menu";
import { deletePort, type Port } from "$lib/api/dashboard/a76/general_catalogs/ports";
import { EllipsisVertical, Pencil, LoaderCircle, Trash2 } from 'lucide-svelte';
import type { Port } from "$lib/api/dashboard/a76/general_catalogs/ports";
import { EllipsisVertical, Pencil, Trash2 } from 'lucide-svelte';
import CreateEditDialog from "./create-edit-dialog.svelte";
import { companyStore } from "$lib/stores/company.svelte";
import DeleteDialog from "./delete-dialog.svelte";
let {
item,
@@ -14,42 +14,8 @@
onSuccess?: () => void;
} = $props();
let loading = $state(false);
let error = $state<string | null>(null);
let dialogOpen = $state(false);
async function handleDelete() {
if (!confirm(`¿Estás seguro de eliminar el puerto "${item.port_code}"?`)) {
return;
}
const companyId = companyStore.activeCompany?.id;
if (!companyId) {
alert('Selecciona una compañía antes de eliminar');
return;
}
loading = true;
error = null;
try {
const response = await deletePort(item.id, companyId);
if (response.error) {
error = response.error;
alert(`Error al eliminar: ${response.error}`);
return;
}
if (onSuccess) onSuccess();
} catch (e) {
error = 'Error de conexión';
console.error(e);
alert('Error de conexión al eliminar');
} finally {
loading = false;
}
}
let editDialogOpen = $state(false);
let deleteDialogOpen = $state(false);
</script>
<DropdownMenu.Root>
@@ -63,24 +29,26 @@
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end">
<DropdownMenu.Label>Acciones</DropdownMenu.Label>
<DropdownMenu.Item onclick={() => dialogOpen = true}>
<DropdownMenu.Item onclick={() => editDialogOpen = true}>
<Pencil class="mr-2 h-4 w-4" />
Editar
</DropdownMenu.Item>
<DropdownMenu.Item onclick={handleDelete} class="text-red-600">
{#if loading}
<LoaderCircle class="mr-2 h-4 w-4 animate-spin" />
{:else}
<Trash2 class="mr-2 h-4 w-4" />
{/if}
<DropdownMenu.Item onclick={() => deleteDialogOpen = true} class="text-destructive">
<Trash2 class="mr-2 h-4 w-4" />
Eliminar
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
<CreateEditDialog
bind:open={dialogOpen}
bind:open={editDialogOpen}
mode="edit"
{item}
{onSuccess}
/>
<DeleteDialog
bind:open={deleteDialogOpen}
{item}
{onSuccess}
/>

View File

@@ -0,0 +1,117 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button';
import * as AlertDialog from '$lib/components/ui/alert-dialog';
import { portsApi, type Port } from '$lib/api/dashboard/a76/general_catalogs/ports';
import { LoaderCircle } from 'lucide-svelte';
import { companyStore } from '$lib/stores/company.svelte';
let {
open = $bindable(false),
item,
onSuccess
}: {
open: boolean;
item: Port | null;
onSuccess?: () => void;
} = $props();
let loading = $state(false);
let error = $state<string | null>(null);
async function handleDelete() {
if (!item) return;
const companyId = companyStore.activeCompany?.id;
if (!companyId) {
error = 'Selecciona una compañía antes de eliminar';
return;
}
loading = true;
error = null;
try {
const response = await portsApi.delete(item.id, companyId);
if (response.error) {
error = response.error;
return;
}
// Éxito
open = false;
if (onSuccess) {
onSuccess();
}
} catch (e) {
error = e instanceof Error ? e.message : 'Error al eliminar';
console.error('Error deleting:', e);
} finally {
loading = false;
}
}
function handleOpenChange(newOpen: boolean) {
if (!newOpen) {
error = null;
}
open = newOpen;
}
</script>
<AlertDialog.Root bind:open onOpenChange={handleOpenChange}>
<AlertDialog.Content>
<AlertDialog.Header>
<AlertDialog.Title>¿Estás seguro?</AlertDialog.Title>
<AlertDialog.Description class="space-y-2">
<p>Esta acción no se puede deshacer. Se eliminará permanentemente este puerto:</p>
{#if item}
<div class="mt-2 rounded-lg bg-muted p-3 space-y-2">
<div class="flex items-center justify-between text-sm">
<span class="font-medium">Código:</span>
<code class="font-mono font-semibold">{item.port_code}</code>
</div>
{#if item.description}
<div class="flex flex-col gap-1 text-sm">
<span class="font-medium">Descripción:</span>
<span class="text-xs">{item.description}</span>
</div>
{/if}
{#if item.location_code}
<div class="flex items-center justify-between text-sm">
<span class="font-medium">Ubicación:</span>
<code class="font-mono font-semibold">{item.location_code}</code>
</div>
{/if}
</div>
{:else}
<div class="mt-2 rounded-lg bg-muted p-3 text-sm italic">
Cargando información del puerto...
</div>
{/if}
{#if error}
<div
class="mt-2 rounded-lg border border-destructive bg-destructive/10 p-3 text-sm text-destructive"
>
{error}
</div>
{/if}
</AlertDialog.Description>
</AlertDialog.Header>
<AlertDialog.Footer>
<AlertDialog.Cancel disabled={loading}>Cancelar</AlertDialog.Cancel>
<AlertDialog.Action
onclick={handleDelete}
disabled={loading}
class="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{#if loading}
<LoaderCircle class="mr-2 h-4 w-4 animate-spin" />
Eliminando...
{:else}
Eliminar
{/if}
</AlertDialog.Action>
</AlertDialog.Footer>
</AlertDialog.Content>
</AlertDialog.Root>

View File

@@ -12,19 +12,19 @@ export function cn(...inputs: ClassValue[]) {
*/
export function getBackendAssetUrl(path: string | null | undefined): string {
if (!path) return '';
// Si ya es una URL completa, retornarla tal cual
if (path.startsWith('http://') || path.startsWith('https://')) {
return path;
}
// Eliminar la / inicial si existe para evitar //
const cleanPath = path.startsWith('/') ? path.slice(1) : path;
// Obtener la base URL del API y limpiar el / final si existe
let baseUrl = import.meta.env.VITE_API_URL || 'http://localhost:8000';
baseUrl = baseUrl.replace(/\/+$/, ''); // Eliminar todas las / del final
return `${baseUrl}/${cleanPath}`;
}
@@ -34,3 +34,39 @@ export type WithoutChild<T> = T extends { child?: any } ? Omit<T, "child"> : T;
export type WithoutChildren<T> = T extends { children?: any } ? Omit<T, "children"> : T;
export type WithoutChildrenOrChild<T> = WithoutChildren<WithoutChild<T>>;
export type WithElementRef<T, U extends HTMLElement = HTMLElement> = T & { ref?: U | null };
/**
* Obtiene el color del badge según el tipo de factura (SCAII Standards)
*/
export function getInvoiceTypeColor(type?: string | null): string {
if (!type) return 'bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-200';
const normalizedType = type.toLowerCase().trim();
// Impo tem (Rojo)
if (normalizedType.includes('impo tem') || normalizedType === 'tem') {
return 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200';
}
// Impo Def (Verde)
if (normalizedType.includes('impo def') || normalizedType === 'def') {
return 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200';
}
// Comp Mex (Morado)
if (normalizedType.includes('comp mex') || normalizedType === 'mex') {
return 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200';
}
// Cam. Reg. (Azul)
if (normalizedType.includes('cam. reg.') || normalizedType.includes('cam reg') || normalizedType === 'cr') {
return 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200';
}
// Expo. (Azul)
if (normalizedType.includes('expo') || normalizedType === 'exdef' || normalizedType === 'pterm') {
return 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200';
}
// Imp. Rep. (Azul Claro)
if (normalizedType.includes('imp. rep.') || normalizedType.includes('imp rep') || normalizedType === 'repar') {
return 'bg-sky-100 text-sky-800 dark:bg-sky-900 dark:text-sky-200';
}
return 'bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-200';
}

View File

@@ -18,10 +18,15 @@
customsSectionsApi,
type CustomsSection
} from '$lib/api/dashboard/reference_data/customs_sections';
import DataTable from '$lib/components/dashboard/reference_data/customs_sections/data-table.svelte';
import { createColumns } from '$lib/components/dashboard/reference_data/customs_sections/columns.js';
import SectionsDataTable from '$lib/components/dashboard/reference_data/customs_sections/data-table.svelte';
import { createColumns as createSectionColumns } from '$lib/components/dashboard/reference_data/customs_sections/columns.js';
import * as Card from '$lib/components/ui/card';
// Specialized Broker Components
import BrokerDataTable from '$lib/components/dashboard/customs_brokers/data-table.svelte';
import { createColumns as createBrokerColumns } from '$lib/components/dashboard/customs_brokers/columns';
import DeleteDialog from '$lib/components/dashboard/customs_brokers/delete-dialog.svelte';
let { data }: { data: any } = $props();
// Global State
@@ -31,6 +36,7 @@
let items = $state<CustomsBroker[]>(data.items || []);
let selectedItem = $state<CustomsBroker | null>(null);
let isLoading = $state(false);
let showDeleteDialog = $state(false);
// Server-side filtering/pagination (for Brokers)
let allItemsRaw = $state<CustomsBroker[]>(data.brokers || []);
@@ -114,18 +120,13 @@
}
async function handleDelete() {
if (!selectedItem || !companyStore.activeCompany?.id) return;
if (!confirm('¿Estás seguro de eliminar este Agente Aduanal?')) return;
try {
await customsBrokersApi.delete(
selectedItem.broker_key,
companyStore.activeCompany.id.toString()
);
toast.success('Agente eliminado');
selectedItem = null;
loadItems();
} catch (e) {
toast.error('Error al eliminar');
}
showDeleteDialog = true;
}
function handleActionSuccess() {
loadItems();
selectedItem = null;
toast.success('Cambios aplicados correctamente');
}
// Customs Sections Actions
@@ -178,10 +179,12 @@
})
);
const sectionsColumns = createColumns(() => {
const sectionsColumns = createSectionColumns(() => {
sectionsPage = 1;
loadSections();
});
const brokerColumns = createBrokerColumns(handleActionSuccess);
</script>
<div class="flex flex-col h-[calc(100vh-4rem)] p-4 gap-4 pb-15">
@@ -264,45 +267,13 @@
</div>
<div class="flex-1 overflow-auto bg-card">
<table class="w-full text-sm">
<thead class="bg-muted text-muted-foreground border-b">
<tr>
<th class="px-3 py-2 text-left w-24">Patente</th>
<th class="px-3 py-2 text-left">Nombre</th>
<th class="px-3 py-2 text-left">Licencia</th>
<th class="px-3 py-2 text-left">Ciudad</th>
</tr>
</thead>
<tbody>
{#if isLoading}
<tr
><td colspan="4" class="text-center py-8 text-muted-foreground">Cargando...</td
></tr
>
{:else if paginatedItems.length === 0}
<tr
><td colspan="4" class="text-center py-8 text-muted-foreground"
>No se encontraron registros</td
></tr
>
{:else}
{#each paginatedItems as item (item.broker_key)}
<tr
class="border-b cursor-pointer transition-colors hover:bg-muted/50 {selectedItem?.broker_key ===
item.broker_key
? 'bg-muted'
: ''}"
onclick={() => selectItem(item)}
>
<td class="px-3 py-2 font-mono font-bold">{item.broker_key}</td>
<td class="px-3 py-2 font-medium">{item.name || '-'}</td>
<td class="px-3 py-2 text-muted-foreground">{item.license || '-'}</td>
<td class="px-3 py-2 text-muted-foreground">{item.city || '-'}</td>
</tr>
{/each}
{/if}
</tbody>
</table>
<BrokerDataTable
data={paginatedItems}
columns={brokerColumns}
onRowClick={selectItem}
selectedId={selectedItem?.broker_key}
idField="broker_key"
/>
</div>
<!-- Simple Pagination Controls -->
{#if totalItems > pageSize}
@@ -434,7 +405,7 @@
<!-- Reusing DataTable from Customs Sections -->
<Card.Root class="h-full flex flex-col border-none shadow-none">
<Card.Content class="flex-1 p-0">
<DataTable
<SectionsDataTable
data={sections}
columns={sectionsColumns}
loading={loadingSections}
@@ -477,3 +448,5 @@
</div>
</div>
</div>
<DeleteDialog bind:open={showDeleteDialog} broker={selectedItem} onSuccess={handleActionSuccess} />

View File

@@ -62,6 +62,11 @@
company_id: ''
});
let brokerKeyError = $state(false);
let licenseError = $state(false);
let brokerKeyTimeout: ReturnType<typeof setTimeout>;
let licenseTimeout: ReturnType<typeof setTimeout>;
// --- 3. CARGA DE DATOS REACTIVA ---
$effect(() => {
const company = companyStore.activeCompany;
@@ -126,6 +131,18 @@
return;
}
if (formData.broker_key.length > 5) {
error = 'La Clave del Agente no debe superar los 5 caracteres';
toast.error(error);
return;
}
if (formData.license && formData.license.length > 4) {
error = 'La Patente no debe superar los 4 dígitos';
toast.error(error);
return;
}
loading = true;
error = null;
try {
@@ -213,19 +230,66 @@
>Clave Agente <span class="text-destructive">*</span></Label
>
<Input
bind:value={formData.broker_key}
value={formData.broker_key}
oninput={(e) => {
const val = e.currentTarget.value.toUpperCase();
if (val.length > 5) {
brokerKeyError = true;
formData.broker_key = val.slice(0, 5);
e.currentTarget.value = formData.broker_key;
clearTimeout(brokerKeyTimeout);
brokerKeyTimeout = setTimeout(() => {
brokerKeyError = false;
}, 3000);
} else {
brokerKeyError = false;
formData.broker_key = val;
}
}}
placeholder="Ej. 550"
maxlength="6"
class={brokerKeyError ? 'border-red-500 focus-visible:ring-red-500' : ''}
disabled={isEdit || loading}
/>
<p class="text-xs text-muted-foreground">
Clave interna o número de patente único.
</p>
{#if brokerKeyError}
<p class="text-[0.8rem] font-medium text-destructive">
La clave no debe superar los 5 caracteres
</p>
{/if}
</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} />
<Input
value={formData.license}
oninput={(e) => {
const val = e.currentTarget.value.replace(/\D/g, '');
if (val.length > 4) {
licenseError = true;
formData.license = val.slice(0, 4);
e.currentTarget.value = formData.license;
clearTimeout(licenseTimeout);
licenseTimeout = setTimeout(() => {
licenseError = false;
}, 3000);
} else {
licenseError = false;
formData.license = val;
}
}}
placeholder="Ej. 3421"
maxlength="5"
class={licenseError ? 'border-red-500 focus-visible:ring-red-500' : ''}
disabled={loading}
/>
{#if licenseError}
<p class="text-[0.8rem] font-medium text-destructive">
La patente no debe superar los 4 dígitos
</p>
{/if}
</div>
</div>

View File

@@ -660,7 +660,7 @@
<!-- Dialog para Insertar/Editar Clase de Activo Fijo -->
<Dialog.Root bind:open={showInsertDialog}>
<Dialog.Content class="!max-w-[1600px] !w-[1600px] !h-[90vh] p-0 overflow-hidden flex flex-col">
<Dialog.Content class="max-w-6xl w-full h-[90vh] p-0 overflow-hidden flex flex-col">
<Dialog.Header class="p-6 pb-4 border-b">
<Dialog.Title>{selectedClass ? 'Editar' : 'Nueva'} Clase de Activo Fijo</Dialog.Title>
</Dialog.Header>