feat(crm): frontend del módulo Tarifario — tarifarios, import Excel y Cotizador

- Cliente API rateSheetsAPI (CRUD, plantilla, import con vista previa, /rate-quote).
- Tarifarios: listado + importar por Excel (plantilla, vista previa, validación) +
  detalle con rutas y activar/vencer.
- Cotizador: calcula opciones de costo por proveedor desde los tarifarios vigentes.
- Nav CRM: Tarifarios y Cotizador.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ernesto Herrera
2026-07-27 09:35:48 -06:00
parent 2ae6901b6a
commit fa542ddf18
5 changed files with 489 additions and 0 deletions

View File

@@ -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<Omit<RateSheet, 'id' | 'created_at' | 'updated_at' | 'lane_count' | 'source_file' | 'created_by' | 'updated_by'>> & {
mode: RateMode; name: string;
};
export interface ImportPreviewRow { row: number; data: Record<string, unknown>; 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<string, string | number | undefined>) {
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<T>(p: Promise<{ data?: T; error?: string }>): Promise<T> {
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<RateSheet[]>(api.get(`/v1/crm/rate-sheets?${qp(companyId, params)}`)),
get: (id: number, companyId: number) => unwrap<RateSheet>(api.get(`/v1/crm/rate-sheets/${id}?${qp(companyId)}`)),
create: (data: RateSheetInput, companyId: number) => unwrap<RateSheet>(api.post(`/v1/crm/rate-sheets?${qp(companyId)}`, data)),
update: (id: number, data: Partial<RateSheetInput>, companyId: number) => unwrap<RateSheet>(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<RateLane[]>(api.get(`/v1/crm/rate-sheets/${id}/lanes?${qp(companyId)}`)),
addLane: (id: number, data: Partial<RateLane>, companyId: number) => unwrap<RateLane>(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<void> {
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<ImportPreview> {
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<RateSheet> {
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<CostResult>(api.post(`/v1/crm/rate-quote?${qp(companyId)}`, req))
};

View File

@@ -49,6 +49,8 @@ export function getNavMain(): NavMainItem[] {
{ title: 'Contactos', url: '/dashboard/crm/contactos' }, { title: 'Contactos', url: '/dashboard/crm/contactos' },
{ title: 'Solicitudes', url: '/dashboard/crm/solicitudes' }, { title: 'Solicitudes', url: '/dashboard/crm/solicitudes' },
{ title: 'Cotizaciones', url: '/dashboard/crm/cotizaciones' }, { 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: 'Prospectos (embudo)', url: '/dashboard/crm/prospectos' },
{ title: 'Oportunidades', url: '/dashboard/crm/oportunidades' }, { title: 'Oportunidades', url: '/dashboard/crm/oportunidades' },
{ title: 'Actividades', url: '/dashboard/crm/actividades' }, { title: 'Actividades', url: '/dashboard/crm/actividades' },

View File

@@ -0,0 +1,113 @@
<script lang="ts">
import { onMount } from 'svelte';
import { Calculator } from '@lucide/svelte';
import * as Card from '$lib/components/ui/card';
import * as Table from '$lib/components/ui/table';
import { Button } from '$lib/components/ui/button';
import { companyStore } from '$lib/stores/company.svelte';
import { rateSheetsAPI, type CostOption, type RateMode } from '$lib/api/crm/rates';
import { crmCatalogs } from '$lib/stores/crm-catalogs.svelte';
import { formatMoney } from '$lib/components/crm/format';
import { toast } from 'svelte-sonner';
const companyId = $derived(companyStore.activeCompany?.id ?? null);
const inputCls = 'rounded-md border bg-transparent px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring';
let f = $state({
mode: 'aereo' as RateMode, origin: '', destination: '', on_date: '',
gross_weight_kg: null as number | null, volume_m3: null as number | null,
equipment_type: '', quantity: 1, dangerous: false
});
let options = $state<CostOption[]>([]);
let calculated = $state(false);
let working = $state(false);
const isFcl = $derived(f.mode === 'maritimo_fcl' || f.mode === 'terrestre');
onMount(() => void crmCatalogs.preload(['modo_tarifario', 'tipo_equipo']));
async function calc() {
if (!companyId) return;
if (!f.destination.trim()) { toast.error('Indica el destino'); return; }
working = true; calculated = false;
try {
const res = await rateSheetsAPI.quote({
mode: f.mode, origin: f.origin || null, destination: f.destination || null,
on_date: f.on_date || null, gross_weight_kg: f.gross_weight_kg, volume_m3: f.volume_m3,
equipment_type: f.equipment_type || null, quantity: f.quantity || 1, dangerous: f.dangerous
}, companyId);
options = res.options; calculated = true;
} catch (e) { toast.error(e instanceof Error ? e.message : 'No se pudo calcular'); }
finally { working = false; }
}
const money = (v: number, c: string | null) => formatMoney(v, c ?? 'USD');
</script>
<div class="space-y-6">
<div>
<h1 class="flex items-center gap-2 text-2xl font-bold tracking-tight"><Calculator class="h-6 w-6" /> Cotizador</h1>
<p class="mt-1 text-sm text-muted-foreground">Calcula el costo por proveedor a partir de los tarifarios vigentes.</p>
</div>
{#if !companyId}
<Card.Root><Card.Content class="pt-6 text-sm text-muted-foreground">Selecciona una compañía activa.</Card.Content></Card.Root>
{:else}
<Card.Root>
<Card.Header><Card.Title class="text-base">Datos de la carga</Card.Title></Card.Header>
<Card.Content>
<div class="grid gap-4 sm:grid-cols-3">
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Modo *</span>
<select class={inputCls} bind:value={f.mode}>{#each crmCatalogs.options('modo_tarifario') 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">Origen</span><input class={inputCls} bind:value={f.origin} placeholder="NLU / MXZLO" /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Destino *</span><input class={inputCls} bind:value={f.destination} placeholder="FRA / CNSHA" /></label>
{#if isFcl}
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Tipo de equipo</span>
<select class={inputCls} bind:value={f.equipment_type}><option value=""></option>{#each crmCatalogs.options('tipo_equipo') 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">Cantidad</span><input type="number" min="1" class={inputCls} bind:value={f.quantity} /></label>
{:else}
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Peso bruto (kg)</span><input type="number" min="0" step="0.01" class={inputCls} bind:value={f.gross_weight_kg} /></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={f.volume_m3} /></label>
{/if}
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Fecha embarque</span><input type="date" class={inputCls} bind:value={f.on_date} /></label>
<label class="flex items-center gap-2 pt-6 text-sm"><input type="checkbox" class="h-4 w-4 rounded border" bind:checked={f.dangerous} /><span>Mercancía peligrosa (DGR)</span></label>
</div>
<div class="mt-4 flex justify-end"><Button onclick={calc} disabled={working}>{working ? 'Calculando…' : 'Calcular costo'}</Button></div>
</Card.Content>
</Card.Root>
{#if calculated}
<Card.Root>
<Card.Header><Card.Title class="text-base">Opciones ({options.length})</Card.Title>
<Card.Description>Ordenadas por costo total. El precio de venta se define en la cotización (costo + margen).</Card.Description>
</Card.Header>
<Card.Content>
{#if options.length === 0}
<p class="text-sm text-muted-foreground">No hay tarifas vigentes para esa ruta/modo. Verifica que exista un tarifario <b>activo</b> con esa ruta.</p>
{:else}
<div class="overflow-x-auto">
<Table.Root>
<Table.Header><Table.Row><Table.Head>Tarifario</Table.Head><Table.Head>Base</Table.Head><Table.Head>Cargos</Table.Head><Table.Head>Total</Table.Head><Table.Head>Detalle</Table.Head><Table.Head>Tránsito</Table.Head></Table.Row></Table.Header>
<Table.Body>
{#each options as o, i (o.rate_sheet_id + '-' + i)}
<Table.Row class={i === 0 ? 'bg-emerald-50/60 dark:bg-emerald-950/20' : ''}>
<Table.Cell class="font-medium">{o.rate_sheet_name}</Table.Cell>
<Table.Cell>{money(o.base_cost, o.currency)}</Table.Cell>
<Table.Cell class="text-xs">{o.charges.length ? o.charges.map((c) => `${c.concept}: ${money(c.amount, o.currency)}`).join(', ') : '—'}</Table.Cell>
<Table.Cell class="font-semibold">{money(o.total_cost, o.currency)}</Table.Cell>
<Table.Cell class="text-xs text-muted-foreground">{o.detail ?? '—'}</Table.Cell>
<Table.Cell>{o.transit_days ?? '—'}</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
</div>
{/if}
</Card.Content>
</Card.Root>
{/if}
{/if}
</div>

View File

@@ -0,0 +1,179 @@
<script lang="ts">
import { onMount } from 'svelte';
import { FileSpreadsheet, Plus, Trash2, Search, Pencil, Upload, Download } from '@lucide/svelte';
import * as Card from '$lib/components/ui/card';
import * as Table from '$lib/components/ui/table';
import { Button } from '$lib/components/ui/button';
import { companyStore } from '$lib/stores/company.svelte';
import { rateSheetsAPI, type RateSheet, type RateMode, type ImportPreview } from '$lib/api/crm/rates';
import { suppliersAPI, type Supplier } from '$lib/api/crm';
import { crmCatalogs } from '$lib/stores/crm-catalogs.svelte';
import { formatDate } from '$lib/components/crm/format';
import { toast } from 'svelte-sonner';
const companyId = $derived(companyStore.activeCompany?.id ?? null);
const inputCls = 'rounded-md border bg-transparent px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring';
let sheets = $state<RateSheet[]>([]);
let suppliers = $state<Supplier[]>([]);
let loading = $state(false);
let modeFilter = $state('');
// modal import
let showImport = $state(false);
let imp = $state({ mode: 'aereo' as RateMode, name: '', supplier_id: null as number | null, currency: 'USD', valid_from: '', valid_to: '', default_origin: '' });
let impFile = $state<File | null>(null);
let preview = $state<ImportPreview | null>(null);
let working = $state(false);
onMount(() => void crmCatalogs.preload(['modo_tarifario']));
$effect(() => { const cid = companyId; if (cid) void load(cid); });
async function load(cid: number) {
loading = true;
try {
sheets = await rateSheetsAPI.list(cid, { mode: modeFilter || undefined });
if (suppliers.length === 0) suppliers = await suppliersAPI.list(cid).catch(() => []);
} catch (e) { toast.error(e instanceof Error ? e.message : 'No se pudieron cargar los tarifarios'); }
finally { loading = false; }
}
const modeLabel = (m: string) => crmCatalogs.label('modo_tarifario', m);
function openImport() {
imp = { mode: 'aereo', name: '', supplier_id: null, currency: 'USD', valid_from: '', valid_to: '', default_origin: '' };
impFile = null; preview = null; showImport = true;
}
async function dlTemplate() {
if (!companyId) return;
try { await rateSheetsAPI.downloadTemplate(imp.mode, companyId); }
catch (e) { toast.error(e instanceof Error ? e.message : 'No se pudo descargar la plantilla'); }
}
function onFile(e: Event) { impFile = (e.target as HTMLInputElement).files?.[0] ?? null; preview = null; }
async function doPreview() {
if (!companyId || !impFile) { toast.error('Selecciona un archivo'); return; }
working = true;
try { preview = await rateSheetsAPI.importPreview(imp.mode, impFile, companyId); }
catch (e) { toast.error(e instanceof Error ? e.message : 'No se pudo leer el archivo'); }
finally { working = false; }
}
async function doImport() {
if (!companyId || !impFile) return;
if (!imp.name.trim()) { toast.error('Ponle nombre al tarifario'); return; }
working = true;
try {
await rateSheetsAPI.importSheet(companyId, {
mode: imp.mode, name: imp.name.trim(), supplier_id: imp.supplier_id,
currency: imp.currency, valid_from: imp.valid_from || null, valid_to: imp.valid_to || null,
default_origin: imp.default_origin || null
}, impFile);
toast.success('Tarifario importado');
showImport = false;
await load(companyId);
} catch (e) { toast.error(e instanceof Error ? e.message : 'No se pudo importar'); }
finally { working = false; }
}
async function remove(s: RateSheet) {
if (!companyId || !confirm(`¿Eliminar el tarifario "${s.name}"?`)) return;
try { await rateSheetsAPI.remove(s.id, companyId); toast.success('Eliminado'); await load(companyId); }
catch (e) { toast.error(e instanceof Error ? e.message : 'No se pudo eliminar'); }
}
const statusCls = (st: string) => st === 'activo' ? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-950/40 dark:text-emerald-400'
: st === 'vencido' || st === 'reemplazado' ? 'bg-muted text-muted-foreground' : 'bg-amber-100 text-amber-700 dark:bg-amber-950/40 dark:text-amber-400';
</script>
<div class="space-y-6">
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
<h1 class="flex items-center gap-2 text-2xl font-bold tracking-tight"><FileSpreadsheet class="h-6 w-6" /> Tarifarios</h1>
<p class="mt-1 text-sm text-muted-foreground">Costos de proveedores por ruta, base de las cotizaciones.</p>
</div>
<Button onclick={openImport} disabled={!companyId}><Upload class="mr-1 h-4 w-4" /> Importar tarifario</Button>
</div>
<Card.Root>
<Card.Header>
<div class="flex flex-wrap items-center gap-3">
<select class={inputCls} bind:value={modeFilter} onchange={() => companyId && load(companyId)}>
<option value="">Todos los modos</option>
{#each crmCatalogs.options('modo_tarifario') as m (m.value)}<option value={m.value}>{m.label}</option>{/each}
</select>
</div>
</Card.Header>
<Card.Content>
{#if loading}
<p class="py-6 text-center text-sm text-muted-foreground">Cargando…</p>
{:else if sheets.length === 0}
<p class="py-6 text-center text-sm text-muted-foreground">Sin tarifarios. Importa uno con el botón de arriba.</p>
{:else}
<div class="overflow-x-auto">
<Table.Root>
<Table.Header><Table.Row><Table.Head>Nombre</Table.Head><Table.Head>Modo</Table.Head><Table.Head>Moneda</Table.Head><Table.Head>Rutas</Table.Head><Table.Head>Vigencia</Table.Head><Table.Head>Estatus</Table.Head><Table.Head class="text-right">Acciones</Table.Head></Table.Row></Table.Header>
<Table.Body>
{#each sheets as s (s.id)}
<Table.Row>
<Table.Cell class="font-medium"><a class="hover:underline" href={`/dashboard/crm/tarifarios/${s.id}`}>{s.name}</a></Table.Cell>
<Table.Cell>{modeLabel(s.mode)}</Table.Cell>
<Table.Cell>{s.currency ?? '—'}</Table.Cell>
<Table.Cell>{s.lane_count ?? 0}</Table.Cell>
<Table.Cell class="text-xs">{s.valid_from ? formatDate(s.valid_from) : '—'}{s.valid_to ? formatDate(s.valid_to) : '—'}</Table.Cell>
<Table.Cell><span class="inline-flex rounded-full px-2 py-0.5 text-xs {statusCls(s.status)}">{s.status}</span></Table.Cell>
<Table.Cell class="text-right">
<Button variant="ghost" size="sm" href={`/dashboard/crm/tarifarios/${s.id}`}><Pencil class="mr-1 h-4 w-4" /> Abrir</Button>
<Button variant="ghost" size="sm" onclick={() => remove(s)} aria-label="Eliminar"><Trash2 class="h-4 w-4 text-destructive" /></Button>
</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
</div>
{/if}
</Card.Content>
</Card.Root>
</div>
{#if showImport}
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" role="presentation" onclick={() => (showImport = false)}>
<div class="max-h-[92vh] w-full max-w-2xl overflow-y-auto rounded-lg border bg-card p-6 shadow-lg" role="dialog" aria-modal="true" tabindex="-1" onclick={(e) => e.stopPropagation()}>
<h3 class="mb-4 flex items-center gap-2 text-base font-semibold"><Upload class="h-4 w-4" /> Importar tarifario</h3>
<div class="grid gap-3 sm:grid-cols-2">
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Modo *</span>
<select class={inputCls} bind:value={imp.mode}>{#each crmCatalogs.options('modo_tarifario') 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">Nombre *</span><input class={inputCls} bind:value={imp.name} placeholder="Tarifario NLU 2º sem 2026" /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Proveedor</span>
<select class={inputCls} bind:value={imp.supplier_id}><option value={null}>—</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">Moneda</span>
<select class={inputCls} bind:value={imp.currency}><option value="USD">USD</option><option value="MXN">MXN</option><option value="EUR">EUR</option></select>
</label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Vigencia desde</span><input type="date" class={inputCls} bind:value={imp.valid_from} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Vigencia hasta</span><input type="date" class={inputCls} bind:value={imp.valid_to} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Origen por defecto</span><input class={inputCls} bind:value={imp.default_origin} placeholder="NLU" /></label>
</div>
<div class="mt-4 flex flex-wrap items-center gap-2 rounded-md border border-dashed p-3">
<Button variant="outline" size="sm" onclick={dlTemplate}><Download class="mr-1 h-4 w-4" /> Descargar plantilla</Button>
<input type="file" accept=".xlsx" class="{inputCls} flex-1" onchange={onFile} />
<Button variant="outline" size="sm" onclick={doPreview} disabled={working || !impFile}>Vista previa</Button>
</div>
{#if preview}
<div class="mt-3 rounded-md border p-3 text-sm">
<p class="mb-2">Filas válidas: <b>{preview.valid}</b> / {preview.total}. Columnas: <span class="font-mono text-xs">{preview.columns.join(', ')}</span></p>
{#if preview.rows.some((r) => r.errors.length)}
<div class="max-h-32 overflow-y-auto text-xs text-destructive">
{#each preview.rows.filter((r) => r.errors.length).slice(0, 20) as r (r.row)}<div>Fila {r.row}: {r.errors.join('; ')}</div>{/each}
</div>
{/if}
</div>
{/if}
<div class="mt-5 flex justify-end gap-2 border-t pt-4">
<Button variant="outline" onclick={() => (showImport = false)}>Cancelar</Button>
<Button onclick={doImport} disabled={working || !impFile || preview?.valid === 0}>{working ? 'Importando…' : 'Importar'}</Button>
</div>
</div>
</div>
{/if}

View File

@@ -0,0 +1,97 @@
<script lang="ts">
import { onMount } from 'svelte';
import { ArrowLeft, FileSpreadsheet, Trash2, CheckCircle2 } from '@lucide/svelte';
import { page } from '$app/state';
import * as Card from '$lib/components/ui/card';
import * as Table from '$lib/components/ui/table';
import { Button } from '$lib/components/ui/button';
import { companyStore } from '$lib/stores/company.svelte';
import { rateSheetsAPI, type RateSheet, type RateLane } from '$lib/api/crm/rates';
import { crmCatalogs } from '$lib/stores/crm-catalogs.svelte';
import { formatDate } from '$lib/components/crm/format';
import { toast } from 'svelte-sonner';
const sheetId = $derived(Number(page.params.id));
const companyId = $derived(companyStore.activeCompany?.id ?? null);
let sheet = $state<RateSheet | null>(null);
let lanes = $state<RateLane[]>([]);
let loading = $state(false);
onMount(() => void crmCatalogs.preload(['modo_tarifario', 'tipo_equipo']));
$effect(() => { const cid = companyId, id = sheetId; if (cid && id) void load(cid, id); });
async function load(cid: number, id: number) {
loading = true;
try { sheet = await rateSheetsAPI.get(id, cid); lanes = await rateSheetsAPI.lanes(id, cid); }
catch (e) { toast.error(e instanceof Error ? e.message : 'No se pudo cargar el tarifario'); }
finally { loading = false; }
}
async function setStatus(status: string) {
if (!companyId || !sheet) return;
try { sheet = await rateSheetsAPI.update(sheet.id, { status }, companyId); toast.success(`Tarifario ${status}`); }
catch (e) { toast.error(e instanceof Error ? e.message : 'No se pudo actualizar'); }
}
async function delLane(l: RateLane) {
if (!companyId || !sheet || !confirm('¿Eliminar esta ruta?')) return;
try { await rateSheetsAPI.removeLane(sheet.id, l.id, companyId); await load(companyId, sheet.id); }
catch (e) { toast.error(e instanceof Error ? e.message : 'No se pudo eliminar'); }
}
const eq = (c: string | null) => c ? crmCatalogs.label('tipo_equipo', c) : '—';
const breaksTxt = (l: RateLane) => (l.breaks ?? []).map((b) => `${b.from_qty}: ${b.rate}`).join(' · ') || '—';
</script>
<div class="space-y-6">
<Button variant="ghost" size="sm" href="/dashboard/crm/tarifarios"><ArrowLeft class="mr-1 h-4 w-4" /> Tarifarios</Button>
{#if loading && !sheet}
<p class="text-sm text-muted-foreground">Cargando…</p>
{:else if sheet}
<div class="flex flex-wrap items-start justify-between gap-3">
<div>
<h1 class="flex items-center gap-2 text-2xl font-bold tracking-tight"><FileSpreadsheet class="h-6 w-6" /> {sheet.name}</h1>
<p class="mt-1 text-sm text-muted-foreground">
{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 <b>{sheet.status}</b>
</p>
</div>
<div class="flex gap-2">
{#if sheet.status !== 'activo'}
<Button size="sm" onclick={() => setStatus('activo')}><CheckCircle2 class="mr-1 h-4 w-4" /> Activar</Button>
{:else}
<Button size="sm" variant="outline" onclick={() => setStatus('vencido')}>Marcar vencido</Button>
{/if}
</div>
</div>
<Card.Root>
<Card.Header><Card.Title class="text-base">Rutas ({lanes.length})</Card.Title>
<Card.Description>El motor de cotización usa estas rutas cuando el tarifario está <b>activo</b> y vigente.</Card.Description>
</Card.Header>
<Card.Content>
{#if lanes.length === 0}
<p class="text-sm text-muted-foreground">Sin rutas.</p>
{:else}
<div class="overflow-x-auto">
<Table.Root>
<Table.Header><Table.Row><Table.Head>Región</Table.Head><Table.Head>Origen</Table.Head><Table.Head>Destino</Table.Head><Table.Head>Equipo</Table.Head><Table.Head>Mínimo</Table.Head><Table.Head>Tarifa / Quiebres</Table.Head><Table.Head>Tránsito</Table.Head><Table.Head></Table.Head></Table.Row></Table.Header>
<Table.Body>
{#each lanes as l (l.id)}
<Table.Row>
<Table.Cell class="text-xs">{l.region ?? '—'}</Table.Cell>
<Table.Cell>{l.origin ?? sheet.default_origin ?? '—'}</Table.Cell>
<Table.Cell class="font-medium">{l.destination ?? '—'}</Table.Cell>
<Table.Cell class="text-xs">{eq(l.equipment_type)}</Table.Cell>
<Table.Cell>{l.min_charge ?? '—'}</Table.Cell>
<Table.Cell class="text-xs">{l.flat_rate != null ? l.flat_rate : breaksTxt(l)}</Table.Cell>
<Table.Cell>{l.transit_days ?? '—'}</Table.Cell>
<Table.Cell class="text-right"><Button variant="ghost" size="sm" onclick={() => delLane(l)} aria-label="Eliminar"><Trash2 class="h-4 w-4 text-destructive" /></Button></Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
</div>
{/if}
</Card.Content>
</Card.Root>
{/if}
</div>