Files
CRM_AGENTES_CARGA/frontend/src/lib/api/crm/commercial.ts
Ernesto Herrera 03055cd377 fix(crm): servir el PDF de cotización por el backend (no exponer MinIO)
La URL prefirmada usaba el host interno http://minio:9000 (no accesible desde el
navegador). Se agrega GET /quotes/{id}/pdf que devuelve el PDF por el backend
(vía nginx) y el visor usa un blob autenticado (api.getBlob).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-30 10:33:14 -06:00

205 lines
9.7 KiB
TypeScript

/**
* Cliente API — Proceso comercial (Solicitudes/RFQ, tarifas, Cotizaciones).
*/
import { api } from '$lib/api';
// ---------- Tipos ----------
export type ServiceRequestStatus = 'nueva' | 'contacto' | 'en_analisis' | 'cotizada' | 'aceptada' | 'rechazada' | 'liberada';
export type QuoteStatus = 'borrador' | 'enviada' | 'aceptada' | 'rechazada';
export interface ServiceRequest {
id: number;
reference: string | null;
account_id: number | null;
opportunity_id: number | null;
operation_type: string;
transport_mode: string | null;
service_type: string | null;
incoterm: string | null;
origin: string | null;
destination: string | null;
cargo_type: string | null;
weight: number | null;
volume: number | null;
load_type: string | null;
container_equipment: string | null;
commodity: string | null;
required_date: string | null;
destination_agent_id: number | null;
requirements: string | null;
first_contact_at: string | null;
first_contact_notes: string | null;
status: ServiceRequestStatus;
notes: string | null;
owner_user_id: string | null;
created_by: string | null;
updated_by: string | null;
tenant_id: number;
company_id: number;
created_at: string;
updated_at: string;
}
export type ServiceRequestInput = Partial<Omit<ServiceRequest, 'id' | 'tenant_id' | 'company_id' | 'created_at' | 'updated_at' | 'created_by' | 'updated_by'>> & {
operation_type: string;
};
export interface RateRequest {
id: number;
service_request_id: number;
supplier_id: number | null;
concept: string;
description: string | null;
status: string;
rate_amount: number | null;
currency: string | null;
valid_until: string | null;
notes: string | null;
tenant_id: number;
company_id: number;
created_at: string;
updated_at: string;
}
export type RateRequestInput = Partial<Omit<RateRequest, 'id' | 'tenant_id' | 'company_id' | 'created_at' | 'updated_at'>> & {
service_request_id: number;
concept: string;
};
export interface Quote {
id: number;
reference: string | null;
service_request_id: number | null;
account_id: number | null;
currency: string;
status: QuoteStatus;
issue_date: string | null;
valid_until: string | null;
total_cost: number;
total_sale: number;
margin: number;
sent_at: string | null;
accepted_at: string | null;
rejected_at: string | null;
notes: string | null;
terms: string | null;
owner_user_id: string | null;
created_by: string | null;
updated_by: string | null;
tenant_id: number;
company_id: number;
created_at: string;
updated_at: string;
}
export type QuoteInput = Partial<Omit<Quote, 'id' | 'status' | 'total_cost' | 'total_sale' | 'margin' | 'sent_at' | 'accepted_at' | 'rejected_at' | 'tenant_id' | 'company_id' | 'created_at' | 'updated_at' | 'created_by' | 'updated_by'>>;
export interface QuoteItem {
id: number;
quote_id: number;
concept: string;
description: string | null;
supplier_id: number | null;
quantity: number;
unit_cost: number;
unit_sale: number;
currency: string | null;
line_cost: number;
line_sale: number;
tenant_id: number;
company_id: number;
}
export type QuoteItemInput = Partial<Omit<QuoteItem, 'id' | 'line_cost' | 'line_sale' | 'tenant_id' | 'company_id'>> & {
quote_id: number;
concept: string;
};
// ---------- Clientes ----------
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 serviceRequestsAPI = {
list: (companyId: number, params?: { search?: string; status?: string; operation_type?: string; account_id?: number }) =>
unwrap<ServiceRequest[]>(api.get(`/v1/crm/service-requests?${qp(companyId, params)}`)),
get: (id: number, companyId: number) => unwrap<ServiceRequest>(api.get(`/v1/crm/service-requests/${id}?${qp(companyId)}`)),
create: (data: ServiceRequestInput, companyId: number) => unwrap<ServiceRequest>(api.post(`/v1/crm/service-requests?${qp(companyId)}`, data)),
update: (id: number, data: Partial<ServiceRequestInput>, companyId: number) => unwrap<ServiceRequest>(api.patch(`/v1/crm/service-requests/${id}?${qp(companyId)}`, data)),
registerContact: (id: number, companyId: number, notes?: string | null) =>
unwrap<ServiceRequest>(api.post(`/v1/crm/service-requests/${id}/contact?${qp(companyId)}`, { notes })),
requote: (id: number, companyId: number) =>
unwrap<ServiceRequest>(api.post(`/v1/crm/service-requests/${id}/requote?${qp(companyId)}`, {})),
fromOpportunity: (opportunityId: number, data: { operation_type: string; transport_mode?: string; service_type?: string; incoterm?: string; origin?: string; destination?: string; notes?: string | null }, companyId: number) =>
unwrap<ServiceRequest>(api.post(`/v1/crm/service-requests/from-opportunity?${qp(companyId, { opportunity_id: opportunityId })}`, data)),
remove: (id: number, companyId: number) => unwrap(api.delete(`/v1/crm/service-requests/${id}?${qp(companyId)}`))
};
export const rateRequestsAPI = {
list: (companyId: number, serviceRequestId?: number) =>
unwrap<RateRequest[]>(api.get(`/v1/crm/rate-requests?${qp(companyId, { service_request_id: serviceRequestId })}`)),
create: (data: RateRequestInput, companyId: number) => unwrap<RateRequest>(api.post(`/v1/crm/rate-requests?${qp(companyId)}`, data)),
update: (id: number, data: Partial<RateRequestInput>, companyId: number) => unwrap<RateRequest>(api.patch(`/v1/crm/rate-requests/${id}?${qp(companyId)}`, data)),
remove: (id: number, companyId: number) => unwrap(api.delete(`/v1/crm/rate-requests/${id}?${qp(companyId)}`))
};
export const quotesAPI = {
list: (companyId: number, params?: { search?: string; status?: string; account_id?: number }) =>
unwrap<Quote[]>(api.get(`/v1/crm/quotes?${qp(companyId, params)}`)),
get: (id: number, companyId: number) => unwrap<Quote>(api.get(`/v1/crm/quotes/${id}?${qp(companyId)}`)),
create: (data: QuoteInput, companyId: number) => unwrap<Quote>(api.post(`/v1/crm/quotes?${qp(companyId)}`, data)),
update: (id: number, data: Partial<QuoteInput>, companyId: number) => unwrap<Quote>(api.patch(`/v1/crm/quotes/${id}?${qp(companyId)}`, data)),
send: (id: number, companyId: number) => unwrap<Quote>(api.patch(`/v1/crm/quotes/${id}/send?${qp(companyId)}`, {})),
accept: (id: number, companyId: number) => unwrap<Quote>(api.patch(`/v1/crm/quotes/${id}/accept?${qp(companyId)}`, {})),
reject: (id: number, companyId: number) => unwrap<Quote>(api.patch(`/v1/crm/quotes/${id}/reject?${qp(companyId)}`, {})),
clone: (id: number, companyId: number) => unwrap<Quote>(api.post(`/v1/crm/quotes/${id}/clone?${qp(companyId)}`, {})),
remove: (id: number, companyId: number) => unwrap(api.delete(`/v1/crm/quotes/${id}?${qp(companyId)}`)),
items: (quoteId: number, companyId: number) => unwrap<QuoteItem[]>(api.get(`/v1/crm/quotes/${quoteId}/items?${qp(companyId)}`)),
pdfBlob: (id: number, companyId: number) => (api as any).getBlob(`/v1/crm/quotes/${id}/pdf?${qp(companyId)}`) as Promise<Blob>,
sendEmail: (id: number, companyId: number, body: { to?: string | null; subject?: string | null; message?: string | null }) =>
unwrap<{ sent_to: string; reference: string }>(api.post(`/v1/crm/quotes/${id}/send-email?${qp(companyId)}`, body))
};
// ---------- Configuración de marca del formato de cotización ----------
export interface QuoteSettings {
id?: number | null;
emitter_name?: string | null; emitter_rfc?: string | null; emitter_address?: string | null;
emitter_phone?: string | null; emitter_email?: string | null; emitter_website?: string | null;
logo_file_key?: string | null; accent_color?: string | null; quote_prefix?: string | null;
default_terms?: string | null; footer_note?: string | null;
}
export const quoteSettingsAPI = {
get: (companyId: number) => unwrap<QuoteSettings>(api.get(`/v1/crm/quote-settings?${qp(companyId)}`)),
save: (companyId: number, data: QuoteSettings) => unwrap<QuoteSettings>(api.put(`/v1/crm/quote-settings?${qp(companyId)}`, data)),
logoUrl: (companyId: number) => unwrap<{ url: string | null }>(api.get(`/v1/crm/quote-settings/logo-url?${qp(companyId)}`)),
async uploadLogo(companyId: number, file: File): Promise<QuoteSettings> {
const fd = new FormData();
fd.append('file', file);
const res = await (api as any).request(`/v1/crm/quote-settings/logo?${qp(companyId)}`, { method: 'POST', body: fd });
if (res.error) throw new Error(res.error);
return res.data as QuoteSettings;
}
};
// ---------- Catálogos de referencia (Incoterms, participantes) ----------
export interface Incoterm { code: string; name: string; }
export interface ParticipantRole { code: string; label: string; source: string; }
export interface Participant { id: number; source: string; name: string; role: string; roles: string[]; }
export const catalogsAPI = {
incoterms: (companyId: number) => unwrap<Incoterm[]>(api.get(`/v1/crm/catalogs/incoterms?${qp(companyId)}`)),
participantRoles: (companyId: number) => unwrap<ParticipantRole[]>(api.get(`/v1/crm/catalogs/participant-roles?${qp(companyId)}`)),
participants: (companyId: number, role?: string) =>
unwrap<Participant[]>(api.get(`/v1/crm/participants?${qp(companyId, { role })}`))
};
export const quoteItemsAPI = {
create: (data: QuoteItemInput, companyId: number) => unwrap<QuoteItem>(api.post(`/v1/crm/quote-items?${qp(companyId)}`, data)),
update: (id: number, data: Partial<QuoteItemInput>, companyId: number) => unwrap<QuoteItem>(api.patch(`/v1/crm/quote-items/${id}?${qp(companyId)}`, data)),
remove: (id: number, companyId: number) => unwrap(api.delete(`/v1/crm/quote-items/${id}?${qp(companyId)}`))
};