Files
CRM_AGENTES_CARGA/frontend/src/lib/components/crm/ServiceRequestFields.svelte
Ernesto Herrera 9c46f5bf3c feat(crm): ajustes de la sesión doc 2 (catálogos, bugs, oportunidades, facturación, UI)
Catálogos y selects:
- Incoterm como catálogo (nuevo catálogo global 'incoterm') en solicitud y modal
  de conversión de oportunidad.
- Moneda como catálogo en Cotizaciones (nuevo/detalle) y Facturación.
- "Tipo de transporte" desde catálogo medio_transporte (antes lista fija).
- Cotizador: origen/destino como selects alineados a las rutas de los tarifarios
  activos (endpoint /rate-locations), para que el costeo siempre encuentre ruta.

Bugs de la sesión:
- Direcciones no guardaban: DTO country String(2)→String(3) (ISO alfa-3); se
  amplía accounts.country y se normaliza 'MX'→'MEX' (migración).
- Contacto de proveedor mal filtrado: contacts.ts ahora envía supplier_id.
- Selects ilegibles en modo oscuro: regla global select option en app.css.
- Formas de pago SAT a 2 dígitos (01/04/08) en catálogo y valores guardados.
- RelatedManager: editar direcciones/contactos/documentos (antes solo eliminar).

Oportunidades:
- Se quitan etapas Prospecto/Contactado del embudo semilla.
- Fechas separadas won_date/lost_date + motivo de pérdida, con modal al mover a
  Ganada/Perdida (migración).

Facturación:
- Folio automático F{AAAA}-{MM}-{NNN} (next_folio entidad F, sin dirección).
- Moneda como catálogo.

UI:
- Giro "otro" habilita campo para especificar (accounts.industry_other, migración).
- Lista de contactos muestra a quién pertenece (cliente/prospecto/proveedor).
- Proveedores: países/puertos/aeropuertos/aduanas por catálogo (select + chips).

Migraciones reversibles (c2d3e4f5a6b7 ya existía; d3e4f5a6b7c8, e4f5a6b7c8d9).
Suite backend en verde (109). svelte-check sin errores nuevos.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-04 08:07:58 -06:00

206 lines
19 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script lang="ts">
import { onMount } from 'svelte';
import type { ServiceRequestInput, Account, Contact, Supplier } from '$lib/api/crm';
import { crmCatalogs } from '$lib/stores/crm-catalogs.svelte';
import { OPERATION_TYPES, TRANSPORT_MODES, SERVICE_TYPES, LOAD_TYPES, PRIORITIES, SR_STATUS } from '$lib/components/crm/format';
let {
form = $bindable(),
tab,
accounts = [],
contacts = [],
suppliers = []
}: {
form: ServiceRequestInput;
tab: string;
accounts?: Account[];
contacts?: Contact[];
suppliers?: Supplier[];
} = $props();
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
);
const airChargeable = $derived(Math.max(Number(form.weight) || 0, airVolumetric));
// 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';
});
// 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
);
function contactName(c: Contact): string {
return [c.first_name, c.last_name].filter(Boolean).join(' ');
}
// Costo estimado por servicio adicional (se traspasa a la cotización)
function serviceCost(code: string): number | undefined {
return form.additional_service_costs?.[code];
}
function setServiceCost(code: string, value: string) {
const map = { ...(form.additional_service_costs ?? {}) };
const n = value === '' ? NaN : Number(value);
if (Number.isNaN(n)) delete map[code];
else map[code] = n;
form.additional_service_costs = map;
}
onMount(() => {
void crmCatalogs.preload([
'pais', 'moneda', 'prioridad', 'tipo_mercancia', 'unidad_medida',
'tipo_embalaje', 'servicio_adicional', 'forma_pago', 'tipo_equipo',
'puerto', 'aeropuerto', 'incoterm', 'medio_transporte'
]);
if (!form.additional_services) form.additional_services = [];
if (!form.additional_service_costs) form.additional_service_costs = {};
});
</script>
{#if tab === 'datos'}
<div class="grid gap-4 sm:grid-cols-2">
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Folio</span><input class="{inputCls} bg-muted/40" bind:value={form.reference} readonly placeholder="Se asigna automáticamente al guardar (ej. S2026-08-001-E)" /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Tipo de operación *</span><select class={inputCls} bind:value={form.operation_type}>{#each OPERATION_TYPES as o (o.value)}<option value={o.value}>{o.label}</option>{/each}</select></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Cliente</span><select class={inputCls} bind:value={form.account_id}><option value={undefined}>—</option>{#each accounts as a (a.id)}<option value={a.id}>{a.name}</option>{/each}</select></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Contacto</span><select class={inputCls} bind:value={form.contact_id}><option value={undefined}>—</option>{#each clientContacts as c (c.id)}<option value={c.id}>{contactName(c)}</option>{/each}</select></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Fecha de la solicitud</span><input type="date" class={inputCls} bind:value={form.request_date} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Prioridad</span><select class={inputCls} bind:value={form.priority}><option value={undefined}>—</option>{#each (crmCatalogs.options('prioridad').length ? crmCatalogs.options('prioridad') : PRIORITIES) 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">Moneda</span><select class={inputCls} bind:value={form.currency}><option value={undefined}>—</option>{#each crmCatalogs.options('moneda') as m (m.value)}<option value={m.value}>{m.value} {m.label}</option>{/each}</select></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Ejecutivo (responsable)</span><input class={inputCls} bind:value={form.owner_user_id} placeholder="Usuario responsable" /></label>
{#if form.status}
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Estatus</span><select class={inputCls} bind:value={form.status}>{#each SR_STATUS as s (s.value)}<option value={s.value}>{s.label}</option>{/each}</select></label>
{/if}
</div>
{:else if tab === 'ruta'}
<div class="grid gap-4 sm:grid-cols-2">
<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>
<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">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>
{: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}
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Lugar de recolección</span><input class={inputCls} bind:value={form.pickup_location} /></label>
<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">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>
{: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}
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Lugar de entrega</span><input class={inputCls} bind:value={form.delivery_location} /></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">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>
</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 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>
<label class="flex items-center gap-2 text-sm"><input type="checkbox" class="h-4 w-4 rounded border" bind:checked={form.hazardous_imo} /><span>Mercancía peligrosa (IMO)</span></label>
<label class="flex items-center gap-2 text-sm"><input type="checkbox" class="h-4 w-4 rounded border" bind:checked={form.refrigerated} /><span>Refrigerada</span></label>
<label class="flex items-center gap-2 text-sm"><input type="checkbox" class="h-4 w-4 rounded border" bind:checked={form.stackable} /><span>Estibable</span></label>
</div>
</div>
{:else if tab === 'dimensiones'}
<div class="grid gap-4 sm:grid-cols-3">
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Piezas</span><input type="number" min="0" class={inputCls} bind:value={form.pieces_count} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Cajas</span><input type="number" min="0" class={inputCls} bind:value={form.boxes_count} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Pallets</span><input type="number" min="0" class={inputCls} bind:value={form.pallets_count} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Peso bruto (kg)</span><input type="number" min="0" step="0.001" class={inputCls} bind:value={form.weight} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Peso neto (kg)</span><input type="number" min="0" step="0.001" class={inputCls} bind:value={form.net_weight} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Volumen (m³)</span><input type="number" min="0" step="0.001" class={inputCls} bind:value={form.volume} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Largo</span><input type="number" min="0" step="0.01" class={inputCls} bind:value={form.length_cm} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Ancho</span><input type="number" min="0" step="0.01" class={inputCls} bind:value={form.width_cm} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Alto</span><input type="number" min="0" step="0.01" class={inputCls} bind:value={form.height_cm} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Unidad de medida</span><select class={inputCls} bind:value={form.measurement_unit}><option value={undefined}>—</option>{#each crmCatalogs.options('unidad_medida') as u (u.value)}<option value={u.value}>{u.label}</option>{/each}</select></label>
</div>
{#if isFcl}
<div class="mt-5 grid gap-4 rounded-md border p-4 sm:grid-cols-2">
<p class="text-sm font-semibold sm:col-span-2">FCL — Contenedor completo</p>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Tipo de contenedor / equipo</span>
{#if crmCatalogs.options('tipo_equipo').length}
<select class={inputCls} bind:value={form.container_equipment}><option value={undefined}>—</option>{#each crmCatalogs.options('tipo_equipo') as e (e.value)}<option value={e.value}>{e.label}</option>{/each}</select>
{:else}
<input class={inputCls} bind:value={form.container_equipment} placeholder="40'HC, 20'DV…" />
{/if}
</label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Cantidad de contenedores</span><input type="number" min="0" class={inputCls} bind:value={form.container_count} /></label>
</div>
{/if}
{#if isLcl}
<div class="mt-4 grid gap-4 rounded-md border p-4 sm:grid-cols-2">
<p class="text-sm font-semibold sm:col-span-2">LCL — Carga consolidada</p>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Tipo de embalaje</span><select class={inputCls} bind:value={form.packaging_type}><option value={undefined}>—</option>{#each crmCatalogs.options('tipo_embalaje') as e (e.value)}<option value={e.value}>{e.label}</option>{/each}</select></label>
<label class="flex items-center gap-2 pt-6 text-sm"><input type="checkbox" class="h-4 w-4 rounded border" bind:checked={form.oversized} /><span>Sobredimensionada</span></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Peso por pallet (kg)</span><input type="number" min="0" step="0.001" class={inputCls} bind:value={form.weight_per_pallet} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Volumen por pallet (m³)</span><input type="number" min="0" step="0.001" class={inputCls} bind:value={form.volume_per_pallet} /></label>
</div>
{/if}
{#if isAir}
<div class="mt-4 grid gap-3 rounded-md border p-4 sm:grid-cols-2">
<p class="text-sm font-semibold sm:col-span-2">Aéreo — Peso / Volumen (P/Vol)</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="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 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>
</div>
{/if}
{:else if tab === 'servicios'}
<p class="mb-1 text-sm font-medium">Servicios adicionales</p>
<p class="mb-3 text-xs text-muted-foreground">Marca los servicios requeridos e indica su costo estimado (opcional). Al cotizar, cada servicio marcado se agrega como concepto de la cotización con ese costo de partida.</p>
<div class="space-y-2">
{#each crmCatalogs.options('servicio_adicional') as s (s.value)}
{@const checked = (form.additional_services ?? []).includes(s.value)}
<div class="flex flex-wrap items-center gap-3">
<label class="flex w-60 items-center gap-2 text-sm"><input type="checkbox" class="h-4 w-4 rounded border" value={s.value} bind:group={form.additional_services} /><span>{s.label}</span></label>
{#if checked}
<div class="flex items-center gap-1">
<input type="number" min="0" step="0.01" class="{inputCls} w-40" placeholder="Costo estimado" value={serviceCost(s.value) ?? ''} oninput={(e) => setServiceCost(s.value, e.currentTarget.value)} />
<span class="text-xs text-muted-foreground">{form.currency ?? ''}</span>
</div>
{/if}
</div>
{/each}
</div>
<label class="mt-5 flex max-w-md flex-col gap-1 text-sm"><span class="font-medium">Forma de pago</span><select class={inputCls} bind:value={form.payment_method}><option value={undefined}>—</option>{#each crmCatalogs.options('forma_pago') as f (f.value)}<option value={f.value}>{f.value} {f.label}</option>{/each}</select></label>
{:else if tab === 'notas'}
<div class="grid gap-4 sm:grid-cols-2">
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Notas del cliente</span><textarea rows="4" class={inputCls} bind:value={form.client_notes}></textarea></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Notas internas</span><textarea rows="4" class={inputCls} bind:value={form.internal_notes}></textarea></label>
<label class="flex flex-col gap-1 text-sm sm:col-span-2"><span class="font-medium">Otros requerimientos</span><textarea rows="3" class={inputCls} bind:value={form.requirements}></textarea></label>
</div>
{/if}