feat(crm): costo estimado por servicio adicional, folios visibles y sidebar por flujo

- Solicitud: al marcar un servicio adicional se habilita su costo estimado
  (columna JSON additional_service_costs). Al cotizar, cada servicio marcado se
  siembra como concepto de la cotización con ese costo de partida (costo=venta).
- Folios visibles: se muestran en la tarjeta de Oportunidad del kanban y se aclara
  en el formulario que el folio se asigna al guardar (las listas ya lo mostraban).
- Sidebar CRM reordenado por flujo comercial (captación → embudo → solicitud →
  cotización → catálogos de apoyo).
- Migración c2d3e4f5a6b7 aditiva y reversible. Suite backend en verde (102).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Ernesto Herrera
2026-08-04 07:00:42 -06:00
parent 915bdd19fe
commit 36e98ee976
11 changed files with 115 additions and 13 deletions

View File

@@ -59,6 +59,7 @@ export interface ServiceRequest {
weight_per_pallet: number | null;
volume_per_pallet: number | null;
additional_services: string[] | null;
additional_service_costs: Record<string, number> | null;
payment_method: string | null;
destination_agent_id: number | null;
requirements: string | null;

View File

@@ -34,6 +34,18 @@
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',
@@ -41,12 +53,13 @@
'puerto', 'aeropuerto'
]);
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 genera automáticamente" /></label>
<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>
@@ -139,13 +152,23 @@
</div>
{/if}
{:else if tab === 'servicios'}
<p class="mb-2 text-sm font-medium">Servicios adicionales</p>
<div class="grid grid-cols-2 gap-2 sm:grid-cols-3">
<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)}
<label class="flex 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>
{@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-4 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>
<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>

View File

@@ -42,17 +42,18 @@ export function getNavMain(): NavMainItem[] {
title: 'CRM',
url: '/dashboard/crm',
icon: Briefcase,
// Orden por flujo comercial: captación → embudo → solicitud → cotización → apoyo
items: [
{ title: 'Panel', url: '/dashboard/crm' },
{ title: 'Clientes / Prospectos', url: '/dashboard/crm/cuentas' },
{ title: 'Proveedores', url: '/dashboard/crm/proveedores' },
{ title: 'Contactos', url: '/dashboard/crm/contactos' },
{ title: 'Prospectos (embudo)', url: '/dashboard/crm/prospectos' },
{ title: 'Oportunidades', url: '/dashboard/crm/oportunidades' },
{ title: 'Solicitudes', url: '/dashboard/crm/solicitudes' },
{ title: 'Cotizaciones', url: '/dashboard/crm/cotizaciones' },
{ title: 'Tarifarios', url: '/dashboard/crm/tarifarios' },
{ title: 'Cotizador', url: '/dashboard/crm/cotizador' },
{ title: 'Prospectos (embudo)', url: '/dashboard/crm/prospectos' },
{ title: 'Oportunidades', url: '/dashboard/crm/oportunidades' },
{ title: 'Proveedores', url: '/dashboard/crm/proveedores' },
{ title: 'Actividades', url: '/dashboard/crm/actividades' },
{ title: 'Catálogos', url: '/dashboard/crm/catalogos' },
],

View File

@@ -276,6 +276,9 @@
ondragstart={(e) => onDragStart(e, opp.id)}
>
<p class="text-sm font-medium">{opp.name}</p>
{#if opp.reference}
<p class="font-mono text-[11px] text-muted-foreground">{opp.reference}</p>
{/if}
{#if accountName(opp.account_id)}
<p class="text-xs text-muted-foreground">{accountName(opp.account_id)}</p>
{/if}

View File

@@ -54,7 +54,7 @@
contactsAPI.list(cid),
rateRequestsAPI.list(cid, id)
]);
form = { ...sr, additional_services: sr.additional_services ?? [] };
form = { ...sr, additional_services: sr.additional_services ?? [], additional_service_costs: sr.additional_service_costs ?? {} };
} catch (e) {
toast.error(e instanceof Error ? e.message : 'No se pudo cargar la solicitud');
} finally {
@@ -67,7 +67,7 @@
saving = true;
try {
sr = await serviceRequestsAPI.update(sr.id, form, companyId);
form = { ...sr, additional_services: sr.additional_services ?? [] };
form = { ...sr, additional_services: sr.additional_services ?? [], additional_service_costs: sr.additional_service_costs ?? {} };
toast.success('Cambios guardados');
} catch (e) {
toast.error(e instanceof Error ? e.message : 'No se pudo guardar');
@@ -82,7 +82,7 @@
busy = true;
try {
sr = await serviceRequestsAPI.registerContact(sr.id, companyId, notes);
form = { ...sr, additional_services: sr.additional_services ?? [] };
form = { ...sr, additional_services: sr.additional_services ?? [], additional_service_costs: sr.additional_service_costs ?? {} };
toast.success('Contacto registrado');
} catch (e) {
toast.error(e instanceof Error ? e.message : 'No se pudo registrar el contacto');
@@ -96,7 +96,7 @@
busy = true;
try {
sr = await serviceRequestsAPI.requote(sr.id, companyId);
form = { ...sr, additional_services: sr.additional_services ?? [] };
form = { ...sr, additional_services: sr.additional_services ?? [], additional_service_costs: sr.additional_service_costs ?? {} };
toast.success('Solicitud reabierta para re-cotizar');
} catch (e) {
toast.error(e instanceof Error ? e.message : 'No se pudo reabrir');

View File

@@ -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: [] });
let form = $state<ServiceRequestInput>({ operation_type: 'exportacion', status: 'nueva', additional_services: [], additional_service_costs: {} });
let accounts = $state<Account[]>([]);
let suppliers = $state<Supplier[]>([]);
let contacts = $state<Contact[]>([]);