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:
@@ -867,3 +867,8 @@ GLOBAL_CATALOGS.update({
|
||||
for _fp in GLOBAL_CATALOGS.get('forma_pago', {}).get('items', []):
|
||||
if len(_fp['code']) == 1:
|
||||
_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)
|
||||
|
||||
79
backend/api/v1/modules/crm/catalogs/seed_locations.py
Normal file
79
backend/api/v1/modules/crm/catalogs/seed_locations.py
Normal 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()
|
||||
@@ -21,25 +21,85 @@
|
||||
const inputCls =
|
||||
'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
|
||||
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');
|
||||
|
||||
// Peso/Volumen aéreo (P/Vol) = (L×A×H cm × cantidad de bultos) / 6000; a cobrar = max(bruto, P/Vol)
|
||||
const airQty = $derived(Number(form.pallets_count) || Number(form.pieces_count) || 1);
|
||||
const airVolumetric = $derived(
|
||||
Number(form.length_cm) > 0 && Number(form.width_cm) > 0 && Number(form.height_cm) > 0
|
||||
? (Number(form.length_cm) * Number(form.width_cm) * Number(form.height_cm) * airQty) / 6000
|
||||
: 0
|
||||
// Modalidad de carga según el tipo de transporte (solo se habilita lo que corresponde)
|
||||
const MODALIDAD_BY_TRANSPORT: Record<string, string[]> = {
|
||||
maritimo: ['FCL', 'LCL', 'AMBAS'],
|
||||
aereo: ['AEREO'],
|
||||
terrestre: ['FTL', 'LTL']
|
||||
// ferroviario / multimodal: sin modalidad
|
||||
};
|
||||
const modalidadOptions = $derived(
|
||||
LOAD_TYPES.filter((l) => (MODALIDAD_BY_TRANSPORT[form.transport_mode ?? ''] ?? []).includes(l.value))
|
||||
);
|
||||
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"
|
||||
$effect(() => {
|
||||
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)
|
||||
const clientContacts = $derived(
|
||||
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 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">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>
|
||||
<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>
|
||||
{#if crmCatalogs.options('ciudad', form.origin_country ?? undefined).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>
|
||||
{: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 crmCatalogs.options('puerto').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 crmCatalogs.options('puerto') as p (p.value)}<option value={p.value}>{p.label}</option>{/each}</select></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}
|
||||
<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}
|
||||
@@ -105,9 +171,13 @@
|
||||
|
||||
<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>
|
||||
{#if crmCatalogs.options('ciudad', form.destination_country ?? undefined).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>
|
||||
{: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 crmCatalogs.options('puerto').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 crmCatalogs.options('puerto') as p (p.value)}<option value={p.value}>{p.label}</option>{/each}</select></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}
|
||||
<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}
|
||||
@@ -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 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>
|
||||
{:else if tab === 'mercancia'}
|
||||
<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">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">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>
|
||||
<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>
|
||||
@@ -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>
|
||||
<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">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="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>
|
||||
{/if}
|
||||
|
||||
@@ -4,22 +4,28 @@
|
||||
import * as Table from '$lib/components/ui/table';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
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 { toast } from 'svelte-sonner';
|
||||
|
||||
let items = $state<ServiceRequest[]>([]);
|
||||
let accounts = $state<Account[]>([]);
|
||||
let loading = $state(false);
|
||||
let search = $state('');
|
||||
let statusFilter = $state('');
|
||||
let clientFilter = $state('');
|
||||
|
||||
const companyId = $derived(companyStore.activeCompany?.id ?? null);
|
||||
function accountName(id: number | null): string {
|
||||
return accounts.find((a) => a.id === id)?.name ?? '—';
|
||||
}
|
||||
const filtered = $derived(
|
||||
items.filter((r) => {
|
||||
if (statusFilter && r.status !== statusFilter) return false;
|
||||
if (clientFilter && String(r.account_id ?? '') !== clientFilter) return false;
|
||||
if (search.trim()) {
|
||||
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;
|
||||
})
|
||||
@@ -34,7 +40,7 @@
|
||||
async function load(cid: number) {
|
||||
loading = true;
|
||||
try {
|
||||
items = await serviceRequestsAPI.list(cid);
|
||||
[items, accounts] = await Promise.all([serviceRequestsAPI.list(cid), accountsAPI.list(cid)]);
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : 'No se pudieron cargar las solicitudes');
|
||||
} finally {
|
||||
@@ -76,6 +82,10 @@
|
||||
<option value="">Todos los estatus</option>
|
||||
{#each SR_STATUS as s (s.value)}<option value={s.value}>{s.label}</option>{/each}
|
||||
</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>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
@@ -89,6 +99,7 @@
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.Head>Folio</Table.Head>
|
||||
<Table.Head>Cliente</Table.Head>
|
||||
<Table.Head>Operación</Table.Head>
|
||||
<Table.Head>Medio</Table.Head>
|
||||
<Table.Head>Ruta</Table.Head>
|
||||
@@ -100,6 +111,7 @@
|
||||
{#each filtered as r (r.id)}
|
||||
<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>{accountName(r.account_id)}</Table.Cell>
|
||||
<Table.Cell>{labelOf(OPERATION_TYPES, r.operation_type)}</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>
|
||||
|
||||
@@ -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">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">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>
|
||||
<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>
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
import ServiceRequestFields from '$lib/components/crm/ServiceRequestFields.svelte';
|
||||
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 suppliers = $state<Supplier[]>([]);
|
||||
let contacts = $state<Contact[]>([]);
|
||||
|
||||
Reference in New Issue
Block a user