From e806512a897ccce6667ff89b1eb2cb8ab58beb4b Mon Sep 17 00:00:00 2001 From: Aduanasoft Date: Wed, 15 Jul 2026 09:08:07 -0600 Subject: [PATCH] feat(ops,fin,crm): frontend de las reglas del PDF (decisiones, cierre, PDF factura, continuidad) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Embarque: "Cerrar operación" (costos finales, habilita facturar), "Reprogramar salida" (Cut Off), y bitácora con puntos de decisión (autorizar/rechazar → hito de corrección) + transporte terrestre/recolección en Datos. - Factura: "Enviar (PDF)" que genera y guarda el PDF, "Ver PDF", y revisión del cliente (en revisión / aprueba / con observaciones); estado en_revision_cliente. - Solicitud: "Registrar contacto" y "Re-cotizar"; Cotización: "Re-cotizar (clonar)"; Oportunidad: "Convertir a solicitud" (enlaza embudo→RFQ). - Clientes API ampliados (ops/fin/crm) + catálogos (incoterms/participantes) + etiquetas de estado. svelte-check: 0 errores de tipo en archivos nuevos. Co-Authored-By: Claude Opus 4.8 (1M context) --- frontend/src/lib/api/crm/commercial.ts | 24 +++- frontend/src/lib/api/fin/index.ts | 11 +- frontend/src/lib/api/ops/index.ts | 19 ++- frontend/src/lib/components/crm/format.ts | 17 +++ .../crm/cotizaciones/[id]/+page.svelte | 17 +++ .../dashboard/crm/oportunidades/+page.svelte | 21 ++- .../crm/solicitudes/[id]/+page.svelte | 43 +++++- .../dashboard/fin/facturas/[id]/+page.svelte | 56 +++++++- .../dashboard/ops/embarques/[id]/+page.svelte | 129 ++++++++++++++++-- 9 files changed, 317 insertions(+), 20 deletions(-) diff --git a/frontend/src/lib/api/crm/commercial.ts b/frontend/src/lib/api/crm/commercial.ts index a762782..a131db6 100644 --- a/frontend/src/lib/api/crm/commercial.ts +++ b/frontend/src/lib/api/crm/commercial.ts @@ -4,13 +4,14 @@ import { api } from '$lib/api'; // ---------- Tipos ---------- -export type ServiceRequestStatus = 'nueva' | 'en_analisis' | 'cotizada' | 'aceptada' | 'rechazada' | 'liberada'; +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; @@ -26,6 +27,8 @@ export interface ServiceRequest { 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; @@ -126,6 +129,12 @@ export const serviceRequestsAPI = { get: (id: number, companyId: number) => unwrap(api.get(`/v1/crm/service-requests/${id}?${qp(companyId)}`)), create: (data: ServiceRequestInput, companyId: number) => unwrap(api.post(`/v1/crm/service-requests?${qp(companyId)}`, data)), update: (id: number, data: Partial, companyId: number) => unwrap(api.patch(`/v1/crm/service-requests/${id}?${qp(companyId)}`, data)), + registerContact: (id: number, companyId: number, notes?: string | null) => + unwrap(api.post(`/v1/crm/service-requests/${id}/contact?${qp(companyId)}`, { notes })), + requote: (id: number, companyId: number) => + unwrap(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(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)}`)) }; @@ -146,10 +155,23 @@ export const quotesAPI = { send: (id: number, companyId: number) => unwrap(api.patch(`/v1/crm/quotes/${id}/send?${qp(companyId)}`, {})), accept: (id: number, companyId: number) => unwrap(api.patch(`/v1/crm/quotes/${id}/accept?${qp(companyId)}`, {})), reject: (id: number, companyId: number) => unwrap(api.patch(`/v1/crm/quotes/${id}/reject?${qp(companyId)}`, {})), + clone: (id: number, companyId: number) => unwrap(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(api.get(`/v1/crm/quotes/${quoteId}/items?${qp(companyId)}`)) }; +// ---------- 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(api.get(`/v1/crm/catalogs/incoterms?${qp(companyId)}`)), + participantRoles: (companyId: number) => unwrap(api.get(`/v1/crm/catalogs/participant-roles?${qp(companyId)}`)), + participants: (companyId: number, role?: string) => + unwrap(api.get(`/v1/crm/participants?${qp(companyId, { role })}`)) +}; + export const quoteItemsAPI = { create: (data: QuoteItemInput, companyId: number) => unwrap(api.post(`/v1/crm/quote-items?${qp(companyId)}`, data)), update: (id: number, data: Partial, companyId: number) => unwrap(api.patch(`/v1/crm/quote-items/${id}?${qp(companyId)}`, data)), diff --git a/frontend/src/lib/api/fin/index.ts b/frontend/src/lib/api/fin/index.ts index 3d4a947..e04e708 100644 --- a/frontend/src/lib/api/fin/index.ts +++ b/frontend/src/lib/api/fin/index.ts @@ -3,7 +3,7 @@ */ import { api } from '$lib/api'; -export type InvoiceStatus = 'borrador' | 'emitida' | 'enviada' | 'pagada' | 'cancelada'; +export type InvoiceStatus = 'borrador' | 'emitida' | 'enviada' | 'en_revision_cliente' | 'pagada' | 'cancelada'; export interface Invoice { id: number; @@ -21,10 +21,15 @@ export interface Invoice { total: number; paid_amount: number; balance: number; + ops_cost_total: number | null; bank_info: string | null; notes: string | null; sent_at: string | null; paid_at: string | null; + pdf_file_key: string | null; + client_reviewed_at: string | null; + client_approved: boolean | null; + review_notes: string | null; owner_user_id: string | null; created_by: string | null; updated_by: string | null; @@ -89,6 +94,10 @@ export const invoicesAPI = { update: (id: number, data: Partial, companyId: number) => unwrap(api.patch(`/v1/fin/invoices/${id}?${qp(companyId)}`, data)), emit: (id: number, companyId: number) => unwrap(api.patch(`/v1/fin/invoices/${id}/emit?${qp(companyId)}`, {})), send: (id: number, companyId: number) => unwrap(api.patch(`/v1/fin/invoices/${id}/send?${qp(companyId)}`, {})), + pdfUrl: (id: number, companyId: number) => unwrap<{ url: string }>(api.get(`/v1/fin/invoices/${id}/pdf-url?${qp(companyId)}`)), + markClientReview: (id: number, companyId: number) => unwrap(api.patch(`/v1/fin/invoices/${id}/client-review?${qp(companyId)}`, {})), + clientDecision: (id: number, approved: boolean, companyId: number, notes?: string | null) => + unwrap(api.patch(`/v1/fin/invoices/${id}/client-decision?${qp(companyId)}`, { approved, notes })), cancel: (id: number, companyId: number) => unwrap(api.patch(`/v1/fin/invoices/${id}/cancel?${qp(companyId)}`, {})), remove: (id: number, companyId: number) => unwrap(api.delete(`/v1/fin/invoices/${id}?${qp(companyId)}`)), items: (id: number, companyId: number) => unwrap(api.get(`/v1/fin/invoices/${id}/items?${qp(companyId)}`)), diff --git a/frontend/src/lib/api/ops/index.ts b/frontend/src/lib/api/ops/index.ts index f60648c..7eb309c 100644 --- a/frontend/src/lib/api/ops/index.ts +++ b/frontend/src/lib/api/ops/index.ts @@ -21,14 +21,21 @@ export interface Shipment { status: ShipmentStatus; booking_number: string | null; carrier_supplier_id: number | null; + ground_carrier_supplier_id: number | null; customs_agent_id: number | null; destination_agent_id: number | null; cutoff_date: string | null; + pickup_at: string | null; etd: string | null; + previous_etd: string | null; eta: string | null; vessel_flight: string | null; container_number: string | null; notes: string | null; + actual_cost_total: number | null; + cost_currency: string | null; + closed_at: string | null; + closed_by: string | null; owner_user_id: string | null; created_by: string | null; updated_by: string | null; @@ -44,7 +51,11 @@ export interface ShipmentEvent { shipment_id: number; event_type: string | null; title: string; - status: string; + kind: string; // hito | decision + status: string; // pendiente | completado | omitido | rechazado | en_correccion + outcome: string | null; // autorizado | rechazado + parent_event_id: number | null; + attempt: number; position: number; planned_date: string | null; actual_date: string | null; @@ -98,6 +109,10 @@ export const shipmentsAPI = { createFromQuote: (quoteId: number, companyId: number) => unwrap(api.post(`/v1/ops/shipments/from-quote?${qp(companyId, { quote_id: quoteId })}`, {})), update: (id: number, data: Partial, companyId: number) => unwrap(api.patch(`/v1/ops/shipments/${id}?${qp(companyId)}`, data)), + reschedule: (id: number, data: { etd?: string | null; cutoff_date?: string | null; reason?: string | null }, companyId: number) => + unwrap(api.post(`/v1/ops/shipments/${id}/reschedule?${qp(companyId)}`, data)), + close: (id: number, data: { actual_cost_total: number; cost_currency?: string; notes?: string | null }, companyId: number) => + unwrap(api.post(`/v1/ops/shipments/${id}/close?${qp(companyId)}`, data)), remove: (id: number, companyId: number) => unwrap(api.delete(`/v1/ops/shipments/${id}?${qp(companyId)}`)), documents: (shipmentId: number, companyId: number) => unwrap(api.get(`/v1/ops/shipments/${shipmentId}/documents?${qp(companyId)}`)), @@ -111,6 +126,8 @@ export const shipmentEventsAPI = { create: (data: ShipmentEventInput, companyId: number) => unwrap(api.post(`/v1/ops/shipment-events?${qp(companyId)}`, data)), update: (id: number, data: Partial, companyId: number) => unwrap(api.patch(`/v1/ops/shipment-events/${id}?${qp(companyId)}`, data)), complete: (id: number, companyId: number) => unwrap(api.patch(`/v1/ops/shipment-events/${id}/complete?${qp(companyId)}`, {})), + decide: (id: number, outcome: 'autorizado' | 'rechazado', companyId: number, notes?: string | null) => + unwrap(api.patch(`/v1/ops/shipment-events/${id}/decision?${qp(companyId)}`, { outcome, notes })), remove: (id: number, companyId: number) => unwrap(api.delete(`/v1/ops/shipment-events/${id}?${qp(companyId)}`)) }; diff --git a/frontend/src/lib/components/crm/format.ts b/frontend/src/lib/components/crm/format.ts index 97969d3..2206adc 100644 --- a/frontend/src/lib/components/crm/format.ts +++ b/frontend/src/lib/components/crm/format.ts @@ -186,6 +186,7 @@ export const LOAD_TYPES: Option[] = [ export const SR_STATUS: Option[] = [ { value: 'nueva', label: 'Nueva' }, + { value: 'contacto', label: 'Contacto realizado' }, { value: 'en_analisis', label: 'En análisis' }, { value: 'cotizada', label: 'Cotizada' }, { value: 'aceptada', label: 'Aceptada' }, @@ -231,11 +232,26 @@ export const DOC_KINDS: Option[] = [ { value: 'otro', label: 'Otro' } ]; +// ----- Bitácora del embarque: hitos y decisiones ----- +export const EVENT_STATUS: Option[] = [ + { value: 'pendiente', label: 'Pendiente' }, + { value: 'completado', label: 'Completado' }, + { value: 'omitido', label: 'Omitido' }, + { value: 'rechazado', label: 'Rechazado' }, + { value: 'en_correccion', label: 'En corrección' } +]; + +export const EVENT_OUTCOME: Option[] = [ + { value: 'autorizado', label: 'Autorizado' }, + { value: 'rechazado', label: 'Rechazado' } +]; + // ----- Facturación ----- export const INVOICE_STATUS: Option[] = [ { value: 'borrador', label: 'Borrador' }, { value: 'emitida', label: 'Emitida' }, { value: 'enviada', label: 'Enviada' }, + { value: 'en_revision_cliente', label: 'En revisión del cliente' }, { value: 'pagada', label: 'Pagada' }, { value: 'cancelada', label: 'Cancelada' } ]; @@ -258,5 +274,6 @@ export const SHIPMENT_DOC_TYPES: Option[] = [ { value: 'packing_list', label: 'Packing List' }, { value: 'carta_encomienda', label: 'Carta Encomienda' }, { value: 'carta_garantia', label: 'Carta Garantía' }, + { value: 'certificado_permiso', label: 'Certificado / Permiso' }, { value: 'otro', label: 'Otro' } ]; diff --git a/frontend/src/routes/dashboard/crm/cotizaciones/[id]/+page.svelte b/frontend/src/routes/dashboard/crm/cotizaciones/[id]/+page.svelte index 9b86414..0f39db5 100644 --- a/frontend/src/routes/dashboard/crm/cotizaciones/[id]/+page.svelte +++ b/frontend/src/routes/dashboard/crm/cotizaciones/[id]/+page.svelte @@ -128,6 +128,20 @@ } } + async function clone() { + if (!companyId || !quote) return; + busy = true; + try { + const nq = await quotesAPI.clone(quote.id, companyId); + toast.success('Cotización clonada como borrador'); + await goto(`/dashboard/crm/cotizaciones/${nq.id}`); + } catch (e) { + toast.error(e instanceof Error ? e.message : 'No se pudo clonar'); + } finally { + busy = false; + } + } + function supplierName(id: number | null | undefined): string { return suppliers.find((s) => s.id === id)?.name ?? '—'; } @@ -165,6 +179,9 @@ {#if quote.status === 'aceptada'} {/if} + {#if quote.status === 'rechazada'} + + {/if} diff --git a/frontend/src/routes/dashboard/crm/oportunidades/+page.svelte b/frontend/src/routes/dashboard/crm/oportunidades/+page.svelte index 3c2b59e..698378d 100644 --- a/frontend/src/routes/dashboard/crm/oportunidades/+page.svelte +++ b/frontend/src/routes/dashboard/crm/oportunidades/+page.svelte @@ -1,5 +1,6 @@