diff --git a/frontend/src/lib/api/crm/rates.ts b/frontend/src/lib/api/crm/rates.ts new file mode 100644 index 0000000..4a3ba52 --- /dev/null +++ b/frontend/src/lib/api/crm/rates.ts @@ -0,0 +1,98 @@ +/** + * Cliente API — Módulo Tarifario (tarifarios, rutas, import Excel, costeo). + */ +import { api } from '$lib/api'; + +export type RateMode = 'aereo' | 'maritimo_fcl' | 'maritimo_lcl' | 'terrestre'; + +export interface RateBreak { from_qty: number; rate: number; } +export interface RateLane { + id: number; rate_sheet_id: number; + origin: string | null; destination: string | null; region: string | null; + equipment_type: string | null; rate_unit: string | null; + min_charge: number | null; flat_rate: number | null; transit_days: number | null; notes: string | null; + breaks: RateBreak[]; +} +export interface RateSheet { + id: number; supplier_id: number | null; mode: RateMode; name: string; + currency: string | null; valid_from: string | null; valid_to: string | null; + default_origin: string | null; status: string; notes: string | null; + source_file: string | null; created_by: string | null; updated_by: string | null; + created_at: string; updated_at: string; lane_count: number | null; +} +export type RateSheetInput = Partial> & { + mode: RateMode; name: string; +}; + +export interface ImportPreviewRow { row: number; data: Record; ok: boolean; warnings: string[]; errors: string[]; } +export interface ImportPreview { mode: RateMode; total: number; valid: number; rows: ImportPreviewRow[]; columns: string[]; } + +export interface CostRequest { + mode: RateMode; origin?: string | null; destination?: string | null; on_date?: string | null; + gross_weight_kg?: number | null; volume_m3?: number | null; equipment_type?: string | null; + quantity?: number; dangerous?: boolean; +} +export interface CostChargeLine { concept: string; amount: number; } +export interface CostOption { + rate_sheet_id: number; rate_sheet_name: string; supplier_id: number | null; currency: string | null; + chargeable: number | null; base_cost: number; charges: CostChargeLine[]; total_cost: number; + transit_days: number | null; detail: string | null; +} +export interface CostResult { request: CostRequest; options: CostOption[]; } + +function qp(companyId: number, extra?: Record) { + const qs = new URLSearchParams({ company_id: String(companyId) }); + for (const [k, v] of Object.entries(extra ?? {})) if (v !== undefined && v !== '') qs.set(k, String(v)); + return qs.toString(); +} +async function unwrap(p: Promise<{ data?: T; error?: string }>): Promise { + const res = await p; + if (res.error) throw new Error(res.error); + return res.data as T; +} + +export const rateSheetsAPI = { + list: (companyId: number, params?: { mode?: string; supplier_id?: number }) => + unwrap(api.get(`/v1/crm/rate-sheets?${qp(companyId, params)}`)), + get: (id: number, companyId: number) => unwrap(api.get(`/v1/crm/rate-sheets/${id}?${qp(companyId)}`)), + create: (data: RateSheetInput, companyId: number) => unwrap(api.post(`/v1/crm/rate-sheets?${qp(companyId)}`, data)), + update: (id: number, data: Partial, companyId: number) => unwrap(api.patch(`/v1/crm/rate-sheets/${id}?${qp(companyId)}`, data)), + remove: (id: number, companyId: number) => unwrap(api.delete(`/v1/crm/rate-sheets/${id}?${qp(companyId)}`)), + lanes: (id: number, companyId: number) => unwrap(api.get(`/v1/crm/rate-sheets/${id}/lanes?${qp(companyId)}`)), + addLane: (id: number, data: Partial, companyId: number) => unwrap(api.post(`/v1/crm/rate-sheets/${id}/lanes?${qp(companyId)}`, data)), + removeLane: (id: number, laneId: number, companyId: number) => unwrap(api.delete(`/v1/crm/rate-sheets/${id}/lanes/${laneId}?${qp(companyId)}`)), + + /** Descarga la plantilla Excel del modo. */ + async downloadTemplate(mode: RateMode, companyId: number): Promise { + const blob = await api.getBlob(`/v1/crm/rate-sheets/template?${qp(companyId, { mode })}`); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; a.download = `plantilla_tarifario_${mode}.xlsx`; + document.body.appendChild(a); a.click(); a.remove(); + URL.revokeObjectURL(url); + }, + + async importPreview(mode: RateMode, file: File, companyId: number): Promise { + const fd = new FormData(); + fd.append('mode', mode); fd.append('file', file); + const res = await (api as any).request(`/v1/crm/rate-sheets/import/preview?${qp(companyId)}`, { method: 'POST', body: fd }); + if (res.error) throw new Error(res.error); + return res.data as ImportPreview; + }, + + async importSheet(companyId: number, header: { mode: RateMode; name: string; supplier_id?: number | null; currency?: string; valid_from?: string | null; valid_to?: string | null; default_origin?: string | null }, file: File): Promise { + const fd = new FormData(); + fd.append('mode', header.mode); fd.append('name', header.name); + if (header.supplier_id != null) fd.append('supplier_id', String(header.supplier_id)); + if (header.currency) fd.append('currency', header.currency); + if (header.valid_from) fd.append('valid_from', header.valid_from); + if (header.valid_to) fd.append('valid_to', header.valid_to); + if (header.default_origin) fd.append('default_origin', header.default_origin); + fd.append('file', file); + const res = await (api as any).request(`/v1/crm/rate-sheets/import?${qp(companyId)}`, { method: 'POST', body: fd }); + if (res.error) throw new Error(res.error); + return res.data as RateSheet; + }, + + quote: (req: CostRequest, companyId: number) => unwrap(api.post(`/v1/crm/rate-quote?${qp(companyId)}`, req)) +}; diff --git a/frontend/src/lib/components/sidebar/modules.ts b/frontend/src/lib/components/sidebar/modules.ts index 2e7bcd4..8f85a92 100644 --- a/frontend/src/lib/components/sidebar/modules.ts +++ b/frontend/src/lib/components/sidebar/modules.ts @@ -49,6 +49,8 @@ export function getNavMain(): NavMainItem[] { { title: 'Contactos', url: '/dashboard/crm/contactos' }, { 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: 'Actividades', url: '/dashboard/crm/actividades' }, diff --git a/frontend/src/routes/dashboard/crm/cotizador/+page.svelte b/frontend/src/routes/dashboard/crm/cotizador/+page.svelte new file mode 100644 index 0000000..0704dcb --- /dev/null +++ b/frontend/src/routes/dashboard/crm/cotizador/+page.svelte @@ -0,0 +1,113 @@ + + +
+
+

Cotizador

+

Calcula el costo por proveedor a partir de los tarifarios vigentes.

+
+ + {#if !companyId} + Selecciona una compañía activa. + {:else} + + Datos de la carga + +
+ + + + + {#if isFcl} + + + {:else} + + + {/if} + + + +
+
+
+
+ + {#if calculated} + + Opciones ({options.length}) + Ordenadas por costo total. El precio de venta se define en la cotización (costo + margen). + + + {#if options.length === 0} +

No hay tarifas vigentes para esa ruta/modo. Verifica que exista un tarifario activo con esa ruta.

+ {:else} +
+ + TarifarioBaseCargosTotalDetalleTránsito + + {#each options as o, i (o.rate_sheet_id + '-' + i)} + + {o.rate_sheet_name} + {money(o.base_cost, o.currency)} + {o.charges.length ? o.charges.map((c) => `${c.concept}: ${money(c.amount, o.currency)}`).join(', ') : '—'} + {money(o.total_cost, o.currency)} + {o.detail ?? '—'} + {o.transit_days ?? '—'} + + {/each} + + +
+ {/if} +
+
+ {/if} + {/if} +
diff --git a/frontend/src/routes/dashboard/crm/tarifarios/+page.svelte b/frontend/src/routes/dashboard/crm/tarifarios/+page.svelte new file mode 100644 index 0000000..4bb981a --- /dev/null +++ b/frontend/src/routes/dashboard/crm/tarifarios/+page.svelte @@ -0,0 +1,179 @@ + + +
+
+
+

Tarifarios

+

Costos de proveedores por ruta, base de las cotizaciones.

+
+ +
+ + + +
+ +
+
+ + {#if loading} +

Cargando…

+ {:else if sheets.length === 0} +

Sin tarifarios. Importa uno con el botón de arriba.

+ {:else} +
+ + NombreModoMonedaRutasVigenciaEstatusAcciones + + {#each sheets as s (s.id)} + + {s.name} + {modeLabel(s.mode)} + {s.currency ?? '—'} + {s.lane_count ?? 0} + {s.valid_from ? formatDate(s.valid_from) : '—'} → {s.valid_to ? formatDate(s.valid_to) : '—'} + {s.status} + + + + + + {/each} + + +
+ {/if} +
+
+
+ +{#if showImport} + +{/if} diff --git a/frontend/src/routes/dashboard/crm/tarifarios/[id]/+page.svelte b/frontend/src/routes/dashboard/crm/tarifarios/[id]/+page.svelte new file mode 100644 index 0000000..41190f9 --- /dev/null +++ b/frontend/src/routes/dashboard/crm/tarifarios/[id]/+page.svelte @@ -0,0 +1,97 @@ + + +
+ + {#if loading && !sheet} +

Cargando…

+ {:else if sheet} +
+
+

{sheet.name}

+

+ {crmCatalogs.label('modo_tarifario', sheet.mode)} · {sheet.currency ?? '—'} · {lanes.length} rutas + · vigencia {sheet.valid_from ? formatDate(sheet.valid_from) : '—'} → {sheet.valid_to ? formatDate(sheet.valid_to) : '—'} + · estatus {sheet.status} +

+
+
+ {#if sheet.status !== 'activo'} + + {:else} + + {/if} +
+
+ + + Rutas ({lanes.length}) + El motor de cotización usa estas rutas cuando el tarifario está activo y vigente. + + + {#if lanes.length === 0} +

Sin rutas.

+ {:else} +
+ + RegiónOrigenDestinoEquipoMínimoTarifa / QuiebresTránsito + + {#each lanes as l (l.id)} + + {l.region ?? '—'} + {l.origin ?? sheet.default_origin ?? '—'} + {l.destination ?? '—'} + {eq(l.equipment_type)} + {l.min_charge ?? '—'} + {l.flat_rate != null ? l.flat_rate : breaksTxt(l)} + {l.transit_days ?? '—'} + + + {/each} + + +
+ {/if} +
+
+ {/if} +