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))
};