feat(crm): refinamientos de Solicitud (Fase A del PDF 07-ago)

- Fecha de solicitud automática (hoy) editable al crear.
- Modalidad de carga dependiente del transporte: marítimo→FCL/LCL/Ambas,
  aéreo→Aérea (autoselección), terrestre→FTL/LTL, ferroviario/multimodal sin modalidad.
- Ciudad y Puerto/Aeropuerto dependientes del país (catálogos por parent_code) con
  respaldo de texto; el campo Puerto/Aeropuerto une ambos catálogos.
- Agente en destino filtrado a proveedores clasificados corresponsal/aduanal.
- Volumen SIEMPRE en m³ (conversión desde dimensiones según unidad de medida).
- P/Vol aéreo etiquetado con unidad (kg) y honra la unidad de medida.
- Moneda visible junto al valor de la mercancía.
- Lista de solicitudes con columna Cliente + filtro por cliente + búsqueda por nombre.
- Formulario de tarifa: campo "Válida hasta" (calendario).
- Catálogos globales ciudad/puerto/aeropuerto por país (seed_locations, extensible).
- Nuevos load types FTL/LTL. Suite backend en verde (109).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Ernesto Herrera
2026-08-07 07:25:20 -06:00
parent 9c46f5bf3c
commit 8c7aeef1a6
6 changed files with 194 additions and 27 deletions

View File

@@ -867,3 +867,8 @@ GLOBAL_CATALOGS.update({
for _fp in GLOBAL_CATALOGS.get('forma_pago', {}).get('items', []): for _fp in GLOBAL_CATALOGS.get('forma_pago', {}).get('items', []):
if len(_fp['code']) == 1: if len(_fp['code']) == 1:
_fp['code'] = _fp['code'].zfill(2) _fp['code'] = _fp['code'].zfill(2)
# Ubicaciones por país (ciudad/puerto/aeropuerto), dependientes de `pais`.
from .seed_locations import LOCATION_CATALOGS # noqa: E402
GLOBAL_CATALOGS.update(LOCATION_CATALOGS)

View File

@@ -0,0 +1,79 @@
"""Catálogos de ubicaciones por país: ciudad, puerto (UN/LOCODE), aeropuerto (IATA).
Dependientes de `pais` (`parent_catalog='pais'`, `parent_code=<ISO3>`). Curado a las
rutas de comercio más usadas (extensible: agregar países/nodos según tarifarios).
Los códigos de puerto/aeropuerto se alinean con los que usan las lanes del tarifario
para que el Cotizador encuentre ruta.
"""
# (ISO3, ciudades[(code,label)], puertos[(code,label)], aeropuertos[(code,label)])
_LOC = [
("MEX",
[("MX-CDMX", "Ciudad de México"), ("MX-GDL", "Guadalajara"), ("MX-MTY", "Monterrey"),
("MX-QRO", "Querétaro"), ("MX-TIJ", "Tijuana"), ("MX-VER", "Veracruz")],
[("MXZLO", "Manzanillo"), ("MXVER", "Veracruz"), ("MXATM", "Altamira"),
("MXLZC", "Lázaro Cárdenas"), ("MXPGO", "Progreso"), ("MXESE", "Ensenada")],
[("MEX", "AICM Ciudad de México"), ("NLU", "AIFA Santa Lucía"), ("GDL", "Guadalajara"),
("MTY", "Monterrey"), ("TIJ", "Tijuana"), ("CUN", "Cancún")]),
("USA",
[("US-LAX", "Los Ángeles"), ("US-NYC", "Nueva York"), ("US-HOU", "Houston"),
("US-CHI", "Chicago"), ("US-MIA", "Miami"), ("US-LRD", "Laredo")],
[("USLAX", "Los Angeles"), ("USLGB", "Long Beach"), ("USNYC", "Nueva York/NJ"),
("USHOU", "Houston"), ("USSAV", "Savannah"), ("USSEA", "Seattle"), ("USOAK", "Oakland")],
[("LAX", "Los Ángeles"), ("JFK", "Nueva York JFK"), ("ORD", "Chicago O'Hare"),
("MIA", "Miami"), ("DFW", "Dallas Fort Worth"), ("ATL", "Atlanta")]),
("CHN",
[("CN-SHA", "Shanghái"), ("CN-SZX", "Shenzhen"), ("CN-CAN", "Guangzhou"),
("CN-NGB", "Ningbo"), ("CN-TAO", "Qingdao"), ("CN-PEK", "Pekín")],
[("CNSHA", "Shanghái"), ("CNNGB", "Ningbo"), ("CNSZX", "Shenzhen"),
("CNTAO", "Qingdao"), ("CNCAN", "Guangzhou"), ("CNXMN", "Xiamen"), ("CNTXG", "Tianjin")],
[("PVG", "Shanghái Pudong"), ("PEK", "Pekín Capital"), ("CAN", "Guangzhou"),
("SZX", "Shenzhen"), ("HKG", "Hong Kong")]),
("DEU",
[("DE-HAM", "Hamburgo"), ("DE-FRA", "Fráncfort"), ("DE-MUC", "Múnich"), ("DE-BER", "Berlín")],
[("DEHAM", "Hamburgo"), ("DEBRV", "Bremerhaven")],
[("FRA", "Fráncfort"), ("MUC", "Múnich"), ("HAM", "Hamburgo")]),
("ESP",
[("ES-MAD", "Madrid"), ("ES-BCN", "Barcelona"), ("ES-VLC", "Valencia")],
[("ESVLC", "Valencia"), ("ESBCN", "Barcelona"), ("ESALG", "Algeciras")],
[("MAD", "Madrid Barajas"), ("BCN", "Barcelona")]),
("NLD",
[("NL-RTM", "Róterdam"), ("NL-AMS", "Ámsterdam")],
[("NLRTM", "Róterdam")],
[("AMS", "Ámsterdam Schiphol")]),
("BRA",
[("BR-SAO", "São Paulo"), ("BR-SSZ", "Santos"), ("BR-RIO", "Río de Janeiro")],
[("BRSSZ", "Santos"), ("BRPNG", "Paranaguá"), ("BRRIG", "Rio Grande")],
[("GRU", "São Paulo Guarulhos"), ("GIG", "Río de Janeiro")]),
("CAN",
[("CA-YVR", "Vancouver"), ("CA-YYZ", "Toronto"), ("CA-YMQ", "Montreal")],
[("CAVAN", "Vancouver"), ("CAMTR", "Montreal"), ("CAHAL", "Halifax")],
[("YVR", "Vancouver"), ("YYZ", "Toronto Pearson")]),
("JPN",
[("JP-TYO", "Tokio"), ("JP-OSA", "Osaka"), ("JP-YOK", "Yokohama")],
[("JPYOK", "Yokohama"), ("JPTYO", "Tokio"), ("JPNGO", "Nagoya"), ("JPKOB", "Kobe")],
[("NRT", "Tokio Narita"), ("HND", "Tokio Haneda"), ("KIX", "Osaka Kansai")]),
("KOR",
[("KR-SEL", "Seúl"), ("KR-PUS", "Busan")],
[("KRPUS", "Busan"), ("KRINC", "Incheon")],
[("ICN", "Seúl Incheon")]),
]
def _build() -> dict:
ciudad, puerto, aeropuerto = [], [], []
for iso3, cities, ports, airports in _LOC:
for code, label in cities:
ciudad.append({"code": code, "label": label, "parent_catalog": "pais", "parent_code": iso3})
for code, label in ports:
puerto.append({"code": code, "label": f"{label} ({code})", "parent_catalog": "pais", "parent_code": iso3})
for code, label in airports:
aeropuerto.append({"code": code, "label": f"{label} ({code})", "parent_catalog": "pais", "parent_code": iso3})
return {
"ciudad": {"label": "Ciudad", "is_system": True, "items": ciudad},
"puerto": {"label": "Puerto", "is_system": True, "items": puerto},
"aeropuerto": {"label": "Aeropuerto", "is_system": True, "items": aeropuerto},
}
LOCATION_CATALOGS = _build()

View File

@@ -21,25 +21,85 @@
const inputCls = const inputCls =
'rounded-md border bg-transparent px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring'; 'rounded-md border bg-transparent px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring';
// FCL/LCL condicionales; "AMBAS" muestra ambas secciones; "AEREO" muestra la sección aérea // Modalidad de carga según el tipo de transporte (solo se habilita lo que corresponde)
const isFcl = $derived(form.load_type === 'FCL' || form.load_type === 'AMBAS'); const MODALIDAD_BY_TRANSPORT: Record<string, string[]> = {
const isLcl = $derived(form.load_type === 'LCL' || form.load_type === 'AMBAS'); maritimo: ['FCL', 'LCL', 'AMBAS'],
const isAir = $derived(form.load_type === 'AEREO'); aereo: ['AEREO'],
terrestre: ['FTL', 'LTL']
// Peso/Volumen aéreo (P/Vol) = (L×A×H cm × cantidad de bultos) / 6000; a cobrar = max(bruto, P/Vol) // ferroviario / multimodal: sin modalidad
const airQty = $derived(Number(form.pallets_count) || Number(form.pieces_count) || 1); };
const airVolumetric = $derived( const modalidadOptions = $derived(
Number(form.length_cm) > 0 && Number(form.width_cm) > 0 && Number(form.height_cm) > 0 LOAD_TYPES.filter((l) => (MODALIDAD_BY_TRANSPORT[form.transport_mode ?? ''] ?? []).includes(l.value))
? (Number(form.length_cm) * Number(form.width_cm) * Number(form.height_cm) * airQty) / 6000
: 0
); );
const airChargeable = $derived(Math.max(Number(form.weight) || 0, airVolumetric)); const showModalidad = $derived(modalidadOptions.length > 0);
// Reglas: al cambiar el transporte, la modalidad inválida se limpia; si solo hay una (aéreo), se autoselecciona
$effect(() => {
const allowed = MODALIDAD_BY_TRANSPORT[form.transport_mode ?? ''] ?? [];
if (allowed.length === 0) {
if (form.load_type) form.load_type = undefined;
return;
}
if (form.load_type && !allowed.includes(form.load_type)) form.load_type = undefined;
if (!form.load_type && allowed.length === 1) form.load_type = allowed[0];
});
// La modalidad aérea fija el medio de transporte en "aéreo" // La modalidad aérea fija el medio de transporte en "aéreo"
$effect(() => { $effect(() => {
if (form.load_type === 'AEREO' && form.transport_mode !== 'aereo') form.transport_mode = 'aereo'; if (form.load_type === 'AEREO' && form.transport_mode !== 'aereo') form.transport_mode = 'aereo';
}); });
const isFcl = $derived(form.load_type === 'FCL' || form.load_type === 'AMBAS');
const isLcl = $derived(form.load_type === 'LCL' || form.load_type === 'AMBAS');
const isAir = $derived(form.load_type === 'AEREO');
// Conversión de dimensiones a cm según la unidad de medida (para volumen m³ y P/Vol)
const UNIT_TO_CM: Record<string, number> = { cm: 1, m: 100, in: 2.54, ft: 30.48 };
const unitCm = $derived(UNIT_TO_CM[form.measurement_unit ?? 'cm'] ?? 1);
const airQty = $derived(Number(form.pallets_count) || Number(form.pieces_count) || 1);
const dimL = $derived((Number(form.length_cm) || 0) * unitCm);
const dimW = $derived((Number(form.width_cm) || 0) * unitCm);
const dimH = $derived((Number(form.height_cm) || 0) * unitCm);
const hasDims = $derived(dimL > 0 && dimW > 0 && dimH > 0);
// Volumen SIEMPRE en m³ (cm³ / 1,000,000)
const volumeM3 = $derived(hasDims ? (dimL * dimW * dimH * airQty) / 1_000_000 : 0);
// P/Vol aéreo (kg) = (L×A×H cm × bultos) / 6000; a cobrar = max(bruto, P/Vol)
const airVolumetric = $derived(hasDims ? (dimL * dimW * dimH * airQty) / 6000 : 0);
const airChargeable = $derived(Math.max(Number(form.weight) || 0, airVolumetric));
// Autocompletar el volumen en m³ a partir de las dimensiones/unidad
$effect(() => {
if (hasDims) form.volume = Math.round(volumeM3 * 1000) / 1000;
});
// Ciudad y puerto/aeropuerto dependen del país (catálogos dependientes, como estado←país)
$effect(() => {
if (form.origin_country) {
void crmCatalogs.ensure('ciudad', form.origin_country);
void crmCatalogs.ensure('puerto', form.origin_country);
void crmCatalogs.ensure('aeropuerto', form.origin_country);
}
});
$effect(() => {
if (form.destination_country) {
void crmCatalogs.ensure('ciudad', form.destination_country);
void crmCatalogs.ensure('puerto', form.destination_country);
void crmCatalogs.ensure('aeropuerto', form.destination_country);
}
});
// Campo "Puerto/Aeropuerto": une puertos + aeropuertos del país
function portOptions(country: string | null | undefined) {
return [
...crmCatalogs.options('puerto', country ?? undefined),
...crmCatalogs.options('aeropuerto', country ?? undefined)
];
}
// Agente en destino: solo proveedores clasificados como corresponsal/aduanal (fallback: todos)
const destinationAgents = $derived(
suppliers.filter((s) => (s.classifications ?? []).some((c) => c === 'agente_corresponsal' || c === 'agente_aduanal'))
);
const agentList = $derived(destinationAgents.length ? destinationAgents : suppliers);
// Contactos del cliente seleccionado (o todos si no hay cliente) // Contactos del cliente seleccionado (o todos si no hay cliente)
const clientContacts = $derived( const clientContacts = $derived(
form.account_id ? contacts.filter((c) => c.account_id === form.account_id) : contacts form.account_id ? contacts.filter((c) => c.account_id === form.account_id) : contacts
@@ -91,13 +151,19 @@
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Tipo de transporte</span><select class={inputCls} bind:value={form.transport_mode}><option value={undefined}>—</option>{#each (crmCatalogs.options('medio_transporte').length ? crmCatalogs.options('medio_transporte') : TRANSPORT_MODES) as m (m.value)}<option value={m.value}>{m.label}</option>{/each}</select></label> <label class="flex flex-col gap-1 text-sm"><span class="font-medium">Tipo de transporte</span><select class={inputCls} bind:value={form.transport_mode}><option value={undefined}>—</option>{#each (crmCatalogs.options('medio_transporte').length ? crmCatalogs.options('medio_transporte') : TRANSPORT_MODES) as m (m.value)}<option value={m.value}>{m.label}</option>{/each}</select></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Tipo de servicio</span><select class={inputCls} bind:value={form.service_type}><option value={undefined}>—</option>{#each SERVICE_TYPES as s (s.value)}<option value={s.value}>{s.label}</option>{/each}</select></label> <label class="flex flex-col gap-1 text-sm"><span class="font-medium">Tipo de servicio</span><select class={inputCls} bind:value={form.service_type}><option value={undefined}>—</option>{#each SERVICE_TYPES as s (s.value)}<option value={s.value}>{s.label}</option>{/each}</select></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Incoterm</span><select class={inputCls} bind:value={form.incoterm}><option value={undefined}>—</option>{#each crmCatalogs.options('incoterm') as i (i.value)}<option value={i.value}>{i.label}</option>{/each}</select></label> <label class="flex flex-col gap-1 text-sm"><span class="font-medium">Incoterm</span><select class={inputCls} bind:value={form.incoterm}><option value={undefined}>—</option>{#each crmCatalogs.options('incoterm') as i (i.value)}<option value={i.value}>{i.label}</option>{/each}</select></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Modalidad de carga</span><select class={inputCls} bind:value={form.load_type}><option value={undefined}>—</option>{#each LOAD_TYPES as l (l.value)}<option value={l.value}>{l.label}</option>{/each}</select></label> {#if showModalidad}
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Modalidad de carga</span><select class={inputCls} bind:value={form.load_type}><option value={undefined}>—</option>{#each modalidadOptions as l (l.value)}<option value={l.value}>{l.label}</option>{/each}</select></label>
{/if}
<p class="mt-1 text-xs font-semibold uppercase text-muted-foreground sm:col-span-2">Origen</p> <p class="mt-1 text-xs font-semibold uppercase text-muted-foreground sm:col-span-2">Origen</p>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">País de origen</span><select class={inputCls} bind:value={form.origin_country}><option value={undefined}>—</option>{#each crmCatalogs.options('pais') as p (p.value)}<option value={p.value}>{p.label}</option>{/each}</select></label> <label class="flex flex-col gap-1 text-sm"><span class="font-medium">País de origen</span><select class={inputCls} bind:value={form.origin_country}><option value={undefined}>—</option>{#each crmCatalogs.options('pais') as p (p.value)}<option value={p.value}>{p.label}</option>{/each}</select></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Ciudad de origen</span><input class={inputCls} bind:value={form.origin_city} /></label> {#if crmCatalogs.options('ciudad', form.origin_country ?? undefined).length}
{#if crmCatalogs.options('puerto').length} <label class="flex flex-col gap-1 text-sm"><span class="font-medium">Ciudad de origen</span><select class={inputCls} bind:value={form.origin_city}><option value={undefined}>—</option>{#each crmCatalogs.options('ciudad', form.origin_country ?? undefined) as c (c.value)}<option value={c.value}>{c.label}</option>{/each}</select></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Puerto/Aeropuerto de origen</span><select class={inputCls} bind:value={form.origin_port}><option value={undefined}>—</option>{#each crmCatalogs.options('puerto') as p (p.value)}<option value={p.value}>{p.label}</option>{/each}</select></label> {:else}
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Ciudad de origen</span><input class={inputCls} bind:value={form.origin_city} /></label>
{/if}
{#if portOptions(form.origin_country).length}
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Puerto/Aeropuerto de origen</span><select class={inputCls} bind:value={form.origin_port}><option value={undefined}>—</option>{#each portOptions(form.origin_country) as p (p.value)}<option value={p.value}>{p.label}</option>{/each}</select></label>
{:else} {:else}
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Puerto/Aeropuerto de origen</span><input class={inputCls} bind:value={form.origin_port} /></label> <label class="flex flex-col gap-1 text-sm"><span class="font-medium">Puerto/Aeropuerto de origen</span><input class={inputCls} bind:value={form.origin_port} /></label>
{/if} {/if}
@@ -105,9 +171,13 @@
<p class="mt-1 text-xs font-semibold uppercase text-muted-foreground sm:col-span-2">Destino</p> <p class="mt-1 text-xs font-semibold uppercase text-muted-foreground sm:col-span-2">Destino</p>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">País de destino</span><select class={inputCls} bind:value={form.destination_country}><option value={undefined}>—</option>{#each crmCatalogs.options('pais') as p (p.value)}<option value={p.value}>{p.label}</option>{/each}</select></label> <label class="flex flex-col gap-1 text-sm"><span class="font-medium">País de destino</span><select class={inputCls} bind:value={form.destination_country}><option value={undefined}>—</option>{#each crmCatalogs.options('pais') as p (p.value)}<option value={p.value}>{p.label}</option>{/each}</select></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Ciudad de destino</span><input class={inputCls} bind:value={form.destination_city} /></label> {#if crmCatalogs.options('ciudad', form.destination_country ?? undefined).length}
{#if crmCatalogs.options('puerto').length} <label class="flex flex-col gap-1 text-sm"><span class="font-medium">Ciudad de destino</span><select class={inputCls} bind:value={form.destination_city}><option value={undefined}>—</option>{#each crmCatalogs.options('ciudad', form.destination_country ?? undefined) as c (c.value)}<option value={c.value}>{c.label}</option>{/each}</select></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Puerto/Aeropuerto de destino</span><select class={inputCls} bind:value={form.destination_port}><option value={undefined}>—</option>{#each crmCatalogs.options('puerto') as p (p.value)}<option value={p.value}>{p.label}</option>{/each}</select></label> {:else}
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Ciudad de destino</span><input class={inputCls} bind:value={form.destination_city} /></label>
{/if}
{#if portOptions(form.destination_country).length}
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Puerto/Aeropuerto de destino</span><select class={inputCls} bind:value={form.destination_port}><option value={undefined}>—</option>{#each portOptions(form.destination_country) as p (p.value)}<option value={p.value}>{p.label}</option>{/each}</select></label>
{:else} {:else}
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Puerto/Aeropuerto de destino</span><input class={inputCls} bind:value={form.destination_port} /></label> <label class="flex flex-col gap-1 text-sm"><span class="font-medium">Puerto/Aeropuerto de destino</span><input class={inputCls} bind:value={form.destination_port} /></label>
{/if} {/if}
@@ -115,14 +185,14 @@
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Fecha estimada de embarque</span><input type="date" class={inputCls} bind:value={form.estimated_shipment_date} /></label> <label class="flex flex-col gap-1 text-sm"><span class="font-medium">Fecha estimada de embarque</span><input type="date" class={inputCls} bind:value={form.estimated_shipment_date} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Fecha requerida</span><input type="date" class={inputCls} bind:value={form.required_date} /></label> <label class="flex flex-col gap-1 text-sm"><span class="font-medium">Fecha requerida</span><input type="date" class={inputCls} bind:value={form.required_date} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Agente en destino</span><select class={inputCls} bind:value={form.destination_agent_id}><option value={undefined}>—</option>{#each suppliers as s (s.id)}<option value={s.id}>{s.name}</option>{/each}</select></label> <label class="flex flex-col gap-1 text-sm"><span class="font-medium">Agente en destino</span><select class={inputCls} bind:value={form.destination_agent_id}><option value={undefined}>—</option>{#each agentList as s (s.id)}<option value={s.id}>{s.name}</option>{/each}</select></label>
</div> </div>
{:else if tab === 'mercancia'} {:else if tab === 'mercancia'}
<div class="grid gap-4 sm:grid-cols-2"> <div class="grid gap-4 sm:grid-cols-2">
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Tipo de mercancía</span><select class={inputCls} bind:value={form.cargo_type}><option value={undefined}>—</option>{#each crmCatalogs.options('tipo_mercancia') as t (t.value)}<option value={t.value}>{t.label}</option>{/each}</select></label> <label class="flex flex-col gap-1 text-sm"><span class="font-medium">Tipo de mercancía</span><select class={inputCls} bind:value={form.cargo_type}><option value={undefined}>—</option>{#each crmCatalogs.options('tipo_mercancia') as t (t.value)}<option value={t.value}>{t.label}</option>{/each}</select></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Fracción arancelaria (HS)</span><input class="font-mono {inputCls}" maxlength="20" bind:value={form.hs_code} /></label> <label class="flex flex-col gap-1 text-sm"><span class="font-medium">Fracción arancelaria (HS)</span><input class="font-mono {inputCls}" maxlength="20" bind:value={form.hs_code} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">País de origen de la mercancía</span><select class={inputCls} bind:value={form.goods_origin_country}><option value={undefined}>—</option>{#each crmCatalogs.options('pais') as p (p.value)}<option value={p.value}>{p.label}</option>{/each}</select></label> <label class="flex flex-col gap-1 text-sm"><span class="font-medium">País de origen de la mercancía</span><select class={inputCls} bind:value={form.goods_origin_country}><option value={undefined}>—</option>{#each crmCatalogs.options('pais') as p (p.value)}<option value={p.value}>{p.label}</option>{/each}</select></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Valor de la mercancía</span><input type="number" min="0" step="0.01" class={inputCls} bind:value={form.cargo_value} /></label> <label class="flex flex-col gap-1 text-sm"><span class="font-medium">Valor de la mercancía</span><div class="flex items-center gap-1"><input type="number" min="0" step="0.01" class="{inputCls} w-full" bind:value={form.cargo_value} /><span class="text-xs text-muted-foreground">{form.currency ?? 'moneda'}</span></div></label>
<label class="flex flex-col gap-1 text-sm sm:col-span-2"><span class="font-medium">Descripción de la mercancía</span><textarea rows="2" class={inputCls} bind:value={form.commodity}></textarea></label> <label class="flex flex-col gap-1 text-sm sm:col-span-2"><span class="font-medium">Descripción de la mercancía</span><textarea rows="2" class={inputCls} bind:value={form.commodity}></textarea></label>
<div class="flex flex-wrap gap-5 sm:col-span-2"> <div class="flex flex-wrap gap-5 sm:col-span-2">
<label class="flex items-center gap-2 text-sm"><input type="checkbox" class="h-4 w-4 rounded border" bind:checked={form.insurance_required} /><span>Requiere seguro</span></label> <label class="flex items-center gap-2 text-sm"><input type="checkbox" class="h-4 w-4 rounded border" bind:checked={form.insurance_required} /><span>Requiere seguro</span></label>
@@ -172,9 +242,9 @@
<p class="text-xs text-muted-foreground sm:col-span-2">P/Vol = (Largo × Ancho × Alto en cm) × cantidad de bultos ÷ 6000 (factor internacional). Se cobra el mayor entre el peso bruto y el P/Vol. Captura Largo/Ancho/Alto y piezas/pallets arriba; el resultado se recalcula solo.</p> <p class="text-xs text-muted-foreground sm:col-span-2">P/Vol = (Largo × Ancho × Alto en cm) × cantidad de bultos ÷ 6000 (factor internacional). Se cobra el mayor entre el peso bruto y el P/Vol. Captura Largo/Ancho/Alto y piezas/pallets arriba; el resultado se recalcula solo.</p>
<div class="rounded-md bg-muted/40 p-3 text-sm sm:col-span-2"> <div class="rounded-md bg-muted/40 p-3 text-sm sm:col-span-2">
<div class="flex justify-between"><span class="text-muted-foreground">Cantidad de bultos</span><span class="font-medium">{airQty}</span></div> <div class="flex justify-between"><span class="text-muted-foreground">Cantidad de bultos</span><span class="font-medium">{airQty}</span></div>
<div class="flex justify-between"><span class="text-muted-foreground">Peso volumétrico (P/Vol)</span><span class="font-medium">{airVolumetric.toFixed(2)}</span></div> <div class="flex justify-between"><span class="text-muted-foreground">Peso volumétrico (P/Vol)</span><span class="font-medium">{airVolumetric.toFixed(2)} kg</span></div>
<div class="flex justify-between"><span class="text-muted-foreground">Peso bruto</span><span class="font-medium">{(Number(form.weight) || 0).toFixed(2)} kg</span></div> <div class="flex justify-between"><span class="text-muted-foreground">Peso bruto</span><span class="font-medium">{(Number(form.weight) || 0).toFixed(2)} kg</span></div>
<div class="mt-1 flex justify-between border-t pt-1"><span class="font-medium">Peso a cobrar</span><span class="font-semibold">{airChargeable.toFixed(2)} kg</span></div> <div class="mt-1 flex justify-between border-t pt-1"><span class="font-medium">Peso a cobrar (P/Vol)</span><span class="font-semibold">{airChargeable.toFixed(2)} kg</span></div>
</div> </div>
</div> </div>
{/if} {/if}

View File

@@ -4,22 +4,28 @@
import * as Table from '$lib/components/ui/table'; import * as Table from '$lib/components/ui/table';
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import { companyStore } from '$lib/stores/company.svelte'; import { companyStore } from '$lib/stores/company.svelte';
import { serviceRequestsAPI, type ServiceRequest } from '$lib/api/crm'; import { serviceRequestsAPI, accountsAPI, type ServiceRequest, type Account } from '$lib/api/crm';
import { OPERATION_TYPES, SR_STATUS, TRANSPORT_MODES, labelOf } from '$lib/components/crm/format'; import { OPERATION_TYPES, SR_STATUS, TRANSPORT_MODES, labelOf } from '$lib/components/crm/format';
import { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
let items = $state<ServiceRequest[]>([]); let items = $state<ServiceRequest[]>([]);
let accounts = $state<Account[]>([]);
let loading = $state(false); let loading = $state(false);
let search = $state(''); let search = $state('');
let statusFilter = $state(''); let statusFilter = $state('');
let clientFilter = $state('');
const companyId = $derived(companyStore.activeCompany?.id ?? null); const companyId = $derived(companyStore.activeCompany?.id ?? null);
function accountName(id: number | null): string {
return accounts.find((a) => a.id === id)?.name ?? '—';
}
const filtered = $derived( const filtered = $derived(
items.filter((r) => { items.filter((r) => {
if (statusFilter && r.status !== statusFilter) return false; if (statusFilter && r.status !== statusFilter) return false;
if (clientFilter && String(r.account_id ?? '') !== clientFilter) return false;
if (search.trim()) { if (search.trim()) {
const q = search.trim().toLowerCase(); const q = search.trim().toLowerCase();
return `${r.reference ?? ''} ${r.origin ?? ''} ${r.destination ?? ''}`.toLowerCase().includes(q); return `${r.reference ?? ''} ${r.origin ?? ''} ${r.destination ?? ''} ${accountName(r.account_id)}`.toLowerCase().includes(q);
} }
return true; return true;
}) })
@@ -34,7 +40,7 @@
async function load(cid: number) { async function load(cid: number) {
loading = true; loading = true;
try { try {
items = await serviceRequestsAPI.list(cid); [items, accounts] = await Promise.all([serviceRequestsAPI.list(cid), accountsAPI.list(cid)]);
} catch (e) { } catch (e) {
toast.error(e instanceof Error ? e.message : 'No se pudieron cargar las solicitudes'); toast.error(e instanceof Error ? e.message : 'No se pudieron cargar las solicitudes');
} finally { } finally {
@@ -76,6 +82,10 @@
<option value="">Todos los estatus</option> <option value="">Todos los estatus</option>
{#each SR_STATUS as s (s.value)}<option value={s.value}>{s.label}</option>{/each} {#each SR_STATUS as s (s.value)}<option value={s.value}>{s.label}</option>{/each}
</select> </select>
<select class={inputCls} bind:value={clientFilter}>
<option value="">Todos los clientes</option>
{#each accounts as a (a.id)}<option value={String(a.id)}>{a.name}</option>{/each}
</select>
</div> </div>
</Card.Header> </Card.Header>
<Card.Content> <Card.Content>
@@ -89,6 +99,7 @@
<Table.Header> <Table.Header>
<Table.Row> <Table.Row>
<Table.Head>Folio</Table.Head> <Table.Head>Folio</Table.Head>
<Table.Head>Cliente</Table.Head>
<Table.Head>Operación</Table.Head> <Table.Head>Operación</Table.Head>
<Table.Head>Medio</Table.Head> <Table.Head>Medio</Table.Head>
<Table.Head>Ruta</Table.Head> <Table.Head>Ruta</Table.Head>
@@ -100,6 +111,7 @@
{#each filtered as r (r.id)} {#each filtered as r (r.id)}
<Table.Row> <Table.Row>
<Table.Cell class="font-medium"><a class="hover:underline" href={`/dashboard/crm/solicitudes/${r.id}`}>{r.reference ?? `#${r.id}`}</a></Table.Cell> <Table.Cell class="font-medium"><a class="hover:underline" href={`/dashboard/crm/solicitudes/${r.id}`}>{r.reference ?? `#${r.id}`}</a></Table.Cell>
<Table.Cell>{accountName(r.account_id)}</Table.Cell>
<Table.Cell>{labelOf(OPERATION_TYPES, r.operation_type)}</Table.Cell> <Table.Cell>{labelOf(OPERATION_TYPES, r.operation_type)}</Table.Cell>
<Table.Cell>{labelOf(TRANSPORT_MODES, r.transport_mode)}</Table.Cell> <Table.Cell>{labelOf(TRANSPORT_MODES, r.transport_mode)}</Table.Cell>
<Table.Cell class="text-sm">{[r.origin, r.destination].filter(Boolean).join(' → ') || '—'}</Table.Cell> <Table.Cell class="text-sm">{[r.origin, r.destination].filter(Boolean).join(' → ') || '—'}</Table.Cell>

View File

@@ -186,6 +186,7 @@
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Tarifa</span><input type="number" min="0" step="0.01" class={inputCls} bind:value={newRate.rate_amount} /></label> <label class="flex flex-col gap-1 text-sm"><span class="font-medium">Tarifa</span><input type="number" min="0" step="0.01" class={inputCls} bind:value={newRate.rate_amount} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Moneda</span><input class={inputCls} maxlength="3" bind:value={newRate.currency} /></label> <label class="flex flex-col gap-1 text-sm"><span class="font-medium">Moneda</span><input class={inputCls} maxlength="3" bind:value={newRate.currency} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Estatus</span><select class={inputCls} bind:value={newRate.status}>{#each RATE_STATUS as s (s.value)}<option value={s.value}>{s.label}</option>{/each}</select></label> <label class="flex flex-col gap-1 text-sm"><span class="font-medium">Estatus</span><select class={inputCls} bind:value={newRate.status}>{#each RATE_STATUS as s (s.value)}<option value={s.value}>{s.label}</option>{/each}</select></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Válida hasta</span><input type="date" class={inputCls} bind:value={newRate.valid_until} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Descripción</span><input class={inputCls} bind:value={newRate.description} /></label> <label class="flex flex-col gap-1 text-sm"><span class="font-medium">Descripción</span><input class={inputCls} bind:value={newRate.description} /></label>
<div class="flex justify-end gap-2 sm:col-span-2"><Button variant="outline" size="sm" onclick={() => (adding = false)}>Cancelar</Button><Button size="sm" onclick={saveRate}>Guardar</Button></div> <div class="flex justify-end gap-2 sm:col-span-2"><Button variant="outline" size="sm" onclick={() => (adding = false)}>Cancelar</Button><Button size="sm" onclick={saveRate}>Guardar</Button></div>
</div> </div>

View File

@@ -9,7 +9,7 @@
import ServiceRequestFields from '$lib/components/crm/ServiceRequestFields.svelte'; import ServiceRequestFields from '$lib/components/crm/ServiceRequestFields.svelte';
import { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
let form = $state<ServiceRequestInput>({ operation_type: 'exportacion', status: 'nueva', additional_services: [], additional_service_costs: {} }); let form = $state<ServiceRequestInput>({ operation_type: 'exportacion', status: 'nueva', additional_services: [], additional_service_costs: {}, request_date: new Date().toISOString().slice(0, 10) });
let accounts = $state<Account[]>([]); let accounts = $state<Account[]>([]);
let suppliers = $state<Supplier[]>([]); let suppliers = $state<Supplier[]>([]);
let contacts = $state<Contact[]>([]); let contacts = $state<Contact[]>([]);