feat(ops,fin,crm): frontend de las reglas del PDF (decisiones, cierre, PDF factura, continuidad)

- 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) <noreply@anthropic.com>
This commit is contained in:
Aduanasoft
2026-07-15 09:08:07 -06:00
parent 3ea8d5f3ef
commit e806512a89
9 changed files with 317 additions and 20 deletions

View File

@@ -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<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)}`))
};
@@ -146,10 +155,23 @@ export const quotesAPI = {
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)}`))
};
// ---------- 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)),

View File

@@ -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<InvoiceInput>, companyId: number) => unwrap<Invoice>(api.patch(`/v1/fin/invoices/${id}?${qp(companyId)}`, data)),
emit: (id: number, companyId: number) => unwrap<Invoice>(api.patch(`/v1/fin/invoices/${id}/emit?${qp(companyId)}`, {})),
send: (id: number, companyId: number) => unwrap<Invoice>(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<Invoice>(api.patch(`/v1/fin/invoices/${id}/client-review?${qp(companyId)}`, {})),
clientDecision: (id: number, approved: boolean, companyId: number, notes?: string | null) =>
unwrap<Invoice>(api.patch(`/v1/fin/invoices/${id}/client-decision?${qp(companyId)}`, { approved, notes })),
cancel: (id: number, companyId: number) => unwrap<Invoice>(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<InvoiceItem[]>(api.get(`/v1/fin/invoices/${id}/items?${qp(companyId)}`)),

View File

@@ -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<Shipment>(api.post(`/v1/ops/shipments/from-quote?${qp(companyId, { quote_id: quoteId })}`, {})),
update: (id: number, data: Partial<ShipmentInput>, companyId: number) => unwrap<Shipment>(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<Shipment>(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<Shipment>(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<ShipmentDocument[]>(api.get(`/v1/ops/shipments/${shipmentId}/documents?${qp(companyId)}`)),
@@ -111,6 +126,8 @@ export const shipmentEventsAPI = {
create: (data: ShipmentEventInput, companyId: number) => unwrap<ShipmentEvent>(api.post(`/v1/ops/shipment-events?${qp(companyId)}`, data)),
update: (id: number, data: Partial<ShipmentEventInput>, companyId: number) => unwrap<ShipmentEvent>(api.patch(`/v1/ops/shipment-events/${id}?${qp(companyId)}`, data)),
complete: (id: number, companyId: number) => unwrap<ShipmentEvent>(api.patch(`/v1/ops/shipment-events/${id}/complete?${qp(companyId)}`, {})),
decide: (id: number, outcome: 'autorizado' | 'rechazado', companyId: number, notes?: string | null) =>
unwrap<ShipmentEvent>(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)}`))
};

View File

@@ -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' }
];

View File

@@ -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'}
<Button size="sm" onclick={release} disabled={busy}><Ship class="mr-1 h-4 w-4" /> Liberar a Operaciones</Button>
{/if}
{#if quote.status === 'rechazada'}
<Button size="sm" variant="outline" onclick={clone} disabled={busy}><Plus class="mr-1 h-4 w-4" /> Re-cotizar (clonar)</Button>
{/if}
</div>
</div>

View File

@@ -1,5 +1,6 @@
<script lang="ts">
import { Target, Plus } from '@lucide/svelte';
import { Target, Plus, FileOutput } from '@lucide/svelte';
import { goto } from '$app/navigation';
import * as Card from '$lib/components/ui/card';
import { Button } from '$lib/components/ui/button';
import { companyStore } from '$lib/stores/company.svelte';
@@ -8,6 +9,7 @@
pipelinesAPI,
stagesAPI,
accountsAPI,
serviceRequestsAPI,
type Opportunity,
type OpportunityInput,
type Pipeline,
@@ -150,6 +152,20 @@
}
}
async function convertToRequest(opp: Opportunity) {
if (!companyId) return;
const op = window.prompt('Convertir a solicitud — tipo de operación (importacion / exportacion):', 'exportacion');
if (!op) return;
const operation_type = op.trim().toLowerCase() === 'importacion' ? 'importacion' : 'exportacion';
try {
const sr = await serviceRequestsAPI.fromOpportunity(opp.id, { operation_type }, companyId);
toast.success('Solicitud creada desde la oportunidad');
await goto(`/dashboard/crm/solicitudes/${sr.id}`);
} catch (e) {
toast.error(e instanceof Error ? e.message : 'No se pudo convertir');
}
}
function onDragStart(event: DragEvent, id: number) {
draggingId = id;
event.dataTransfer?.setData('text/plain', String(id));
@@ -248,6 +264,9 @@
<span class="text-[10px] text-muted-foreground">{opp.probability}%</span>
{/if}
</div>
<button type="button" class="mt-2 inline-flex items-center gap-1 text-[11px] text-primary hover:underline" onclick={() => convertToRequest(opp)}>
<FileOutput class="h-3 w-3" /> Convertir a solicitud
</button>
</div>
{/each}
</div>

View File

@@ -27,6 +27,7 @@
let tab = $state('requerimientos');
let loading = $state(false);
let saving = $state(false);
let busy = $state(false);
let adding = $state(false);
let newRate = $state<RateRequestInput>({ service_request_id: 0, concept: 'flete_internacional', status: 'solicitada' });
@@ -68,6 +69,35 @@
}
}
async function registerContact() {
if (!companyId || !sr) return;
const notes = window.prompt('Nota del contacto al cliente (opcional):') ?? null;
busy = true;
try {
sr = await serviceRequestsAPI.registerContact(sr.id, companyId, notes);
form = { ...sr };
toast.success('Contacto registrado');
} catch (e) {
toast.error(e instanceof Error ? e.message : 'No se pudo registrar el contacto');
} finally {
busy = false;
}
}
async function requote() {
if (!companyId || !sr) return;
busy = true;
try {
sr = await serviceRequestsAPI.requote(sr.id, companyId);
form = { ...sr };
toast.success('Solicitud reabierta para re-cotizar');
} catch (e) {
toast.error(e instanceof Error ? e.message : 'No se pudo reabrir');
} finally {
busy = false;
}
}
function startAdd() {
newRate = { service_request_id: srId, concept: 'flete_internacional', status: 'solicitada', currency: 'USD' };
adding = true;
@@ -104,10 +134,17 @@
{#if loading && !sr}
<p class="text-sm text-muted-foreground">Cargando…</p>
{:else if sr}
<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"><FileText class="h-6 w-6" /> {sr.reference ?? `Solicitud #${sr.id}`}</h1>
<p class="mt-1 text-sm text-muted-foreground">{labelOf(OPERATION_TYPES, sr.operation_type)} · {labelOf(SR_STATUS, sr.status)}</p>
</div>
<div class="flex flex-wrap gap-2">
{#if sr.status === 'nueva' || sr.status === 'contacto'}<Button size="sm" variant="outline" onclick={registerContact} disabled={busy}>Registrar contacto</Button>{/if}
{#if sr.status === 'rechazada' || sr.status === 'cotizada'}<Button size="sm" variant="outline" onclick={requote} disabled={busy}>Re-cotizar</Button>{/if}
</div>
</div>
{#if sr.first_contact_at}<p class="text-xs text-muted-foreground">Contacto registrado{#if sr.first_contact_notes}: {sr.first_contact_notes}{/if}</p>{/if}
<Card.Root>
<Card.Content class="pt-6">

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import { ArrowLeft, Receipt, Plus, Trash2, Send, FileCheck, X } from '@lucide/svelte';
import { ArrowLeft, Receipt, Plus, Trash2, Send, FileCheck, X, FileText, Check, ClipboardCheck } from '@lucide/svelte';
import { page } from '$app/state';
import * as Card from '$lib/components/ui/card';
import * as Table from '$lib/components/ui/table';
@@ -80,7 +80,46 @@
try {
invoice = await invoicesAPI[action](invoice.id, companyId);
form = { ...invoice };
toast.success('Factura actualizada');
toast.success(action === 'send' ? 'Factura enviada (PDF generado)' : 'Factura actualizada');
} catch (e) {
toast.error(e instanceof Error ? e.message : 'No se pudo actualizar');
} finally {
busy = false;
}
}
async function openPdf() {
if (!companyId || !invoice) return;
try {
const { url } = await invoicesAPI.pdfUrl(invoice.id, companyId);
window.open(url, '_blank', 'noopener');
} catch (e) {
toast.error(e instanceof Error ? e.message : 'No se pudo abrir el PDF');
}
}
async function markReview() {
if (!companyId || !invoice) return;
busy = true;
try {
invoice = await invoicesAPI.markClientReview(invoice.id, companyId);
form = { ...invoice };
toast.success('Factura en revisión del cliente');
} catch (e) {
toast.error(e instanceof Error ? e.message : 'No se pudo actualizar');
} finally {
busy = false;
}
}
async function clientDecision(approved: boolean) {
if (!companyId || !invoice) return;
const notes = approved ? null : (window.prompt('Observaciones del cliente:') ?? null);
busy = true;
try {
invoice = await invoicesAPI.clientDecision(invoice.id, approved, companyId, notes);
form = { ...invoice };
toast.success(approved ? 'Aprobada por el cliente' : 'Registrada con observaciones');
} catch (e) {
toast.error(e instanceof Error ? e.message : 'No se pudo actualizar');
} finally {
@@ -114,6 +153,7 @@
borrador: 'bg-slate-100 text-slate-600 dark:bg-slate-800 dark:text-slate-400',
emitida: 'bg-blue-100 text-blue-700 dark:bg-blue-950/40 dark:text-blue-400',
enviada: 'bg-cyan-100 text-cyan-700 dark:bg-cyan-950/40 dark:text-cyan-400',
en_revision_cliente: 'bg-amber-100 text-amber-700 dark:bg-amber-950/40 dark:text-amber-400',
pagada: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-950/40 dark:text-emerald-400',
cancelada: 'bg-red-100 text-red-700 dark:bg-red-950/40 dark:text-red-400'
};
@@ -133,11 +173,21 @@
</div>
<div class="flex flex-wrap gap-2">
{#if invoice.status === 'borrador'}<Button size="sm" variant="outline" onclick={() => doAction('emit')} disabled={busy}><FileCheck class="mr-1 h-4 w-4" /> Emitir</Button>{/if}
{#if invoice.status === 'emitida'}<Button size="sm" variant="outline" onclick={() => doAction('send')} disabled={busy}><Send class="mr-1 h-4 w-4" /> Enviar</Button>{/if}
{#if invoice.status === 'emitida'}<Button size="sm" variant="outline" onclick={() => doAction('send')} disabled={busy}><Send class="mr-1 h-4 w-4" /> Enviar (PDF)</Button>{/if}
{#if invoice.pdf_file_key}<Button size="sm" variant="outline" onclick={openPdf}><FileText class="mr-1 h-4 w-4" /> Ver PDF</Button>{/if}
{#if invoice.status === 'enviada'}<Button size="sm" variant="outline" onclick={markReview} disabled={busy}><ClipboardCheck class="mr-1 h-4 w-4" /> En revisión</Button>{/if}
{#if invoice.status === 'enviada' || invoice.status === 'en_revision_cliente'}
<Button size="sm" variant="outline" onclick={() => clientDecision(true)} disabled={busy}><Check class="mr-1 h-4 w-4 text-emerald-600" /> Cliente aprueba</Button>
<Button size="sm" variant="outline" onclick={() => clientDecision(false)} disabled={busy}><X class="mr-1 h-4 w-4 text-destructive" /> Con observaciones</Button>
{/if}
{#if invoice.status !== 'cancelada' && invoice.status !== 'pagada'}<Button size="sm" variant="outline" onclick={() => doAction('cancel')} disabled={busy}><X class="mr-1 h-4 w-4" /> Cancelar</Button>{/if}
</div>
</div>
{#if invoice.client_reviewed_at}
<p class="text-xs text-muted-foreground">Revisión del cliente: {invoice.client_approved ? 'aprobada' : 'con observaciones'}{#if invoice.review_notes}{invoice.review_notes}{/if}</p>
{/if}
<div class="grid gap-4 sm:grid-cols-4">
<Card.Root><Card.Header><Card.Description>Subtotal</Card.Description><Card.Title class="text-lg">{formatMoney(invoice.subtotal, invoice.currency)}</Card.Title></Card.Header></Card.Root>
<Card.Root><Card.Header><Card.Description>Impuesto ({invoice.tax_rate}%)</Card.Description><Card.Title class="text-lg">{formatMoney(invoice.tax_amount, invoice.currency)}</Card.Title></Card.Header></Card.Root>

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import { ArrowLeft, Ship, Plus, Trash2, Check, Receipt, ListChecks, Upload } from '@lucide/svelte';
import { ArrowLeft, Ship, Plus, Trash2, Check, X, Receipt, ListChecks, Upload, Lock, CalendarClock, GitBranch } from '@lucide/svelte';
import { page } from '$app/state';
import { goto } from '$app/navigation';
import * as Card from '$lib/components/ui/card';
@@ -15,7 +15,7 @@
import { uploadFile, uploadUrl } from '$lib/api/uploads';
import {
OPERATION_TYPES, TRANSPORT_MODES, SERVICE_TYPES, SHIPMENT_STATUS,
DOC_KINDS, SHIPMENT_DOC_TYPES, labelOf, formatDate
DOC_KINDS, SHIPMENT_DOC_TYPES, EVENT_STATUS, labelOf, formatDate
} from '$lib/components/crm/format';
import { toast } from 'svelte-sonner';
@@ -37,6 +37,13 @@
let newDoc = $state<ShipmentDocumentInput>({ shipment_id: 0, doc_kind: 'master', doc_type: 'MBL' });
let addingEvent = $state(false);
let newEvent = $state<{ title: string; notes?: string }>({ title: '' });
let showClose = $state(false);
let closeForm = $state<{ actual_cost_total: number | null; cost_currency: string; notes: string }>({ actual_cost_total: null, cost_currency: 'MXN', notes: '' });
let showReschedule = $state(false);
let rescheduleForm = $state<{ etd: string; cutoff_date: string; reason: string }>({ etd: '', cutoff_date: '', reason: '' });
const canInvoice = $derived(shipment?.status === 'cerrada');
const canClose = $derived(!!shipment && shipment.status !== 'cerrada' && shipment.status !== 'cancelada');
$effect(() => {
const cid = companyId;
@@ -89,6 +96,50 @@
}
}
async function closeOperation() {
if (!companyId || !shipment) return;
if (closeForm.actual_cost_total == null || Number.isNaN(Number(closeForm.actual_cost_total))) {
toast.error('Captura el costo final de la operación');
return;
}
busy = true;
try {
shipment = await shipmentsAPI.close(
shipment.id,
{ actual_cost_total: Number(closeForm.actual_cost_total), cost_currency: closeForm.cost_currency, notes: closeForm.notes || null },
companyId
);
form = { ...shipment };
showClose = false;
toast.success('Cierre operativo registrado; el embarque ya puede facturarse');
} catch (e) {
toast.error(e instanceof Error ? e.message : 'No se pudo cerrar la operación');
} finally {
busy = false;
}
}
async function doReschedule() {
if (!companyId || !shipment) return;
busy = true;
try {
shipment = await shipmentsAPI.reschedule(
shipment.id,
{ etd: rescheduleForm.etd || null, cutoff_date: rescheduleForm.cutoff_date || null, reason: rescheduleForm.reason || null },
companyId
);
form = { ...shipment };
events = await shipmentsAPI.events(shipmentId, companyId);
showReschedule = false;
rescheduleForm = { etd: '', cutoff_date: '', reason: '' };
toast.success('Salida reprogramada');
} catch (e) {
toast.error(e instanceof Error ? e.message : 'No se pudo reprogramar');
} finally {
busy = false;
}
}
// ----- Documentos -----
function startDoc() { newDoc = { shipment_id: shipmentId, doc_kind: 'master', doc_type: 'MBL' }; addingDoc = true; }
@@ -153,8 +204,24 @@
}
async function completeEvent(ev: ShipmentEvent) {
if (!companyId) return;
try {
await shipmentEventsAPI.complete(ev.id, companyId);
events = await shipmentsAPI.events(shipmentId, companyId);
} catch (e) {
toast.error(e instanceof Error ? e.message : 'No se pudo completar el hito');
}
}
async function decideEvent(ev: ShipmentEvent, outcome: 'autorizado' | 'rechazado') {
if (!companyId) return;
let notes: string | null = null;
if (outcome === 'rechazado') notes = window.prompt('Motivo del rechazo (se abrirá un hito de corrección):') ?? null;
try {
await shipmentEventsAPI.decide(ev.id, outcome, companyId, notes);
events = await shipmentsAPI.events(shipmentId, companyId);
toast.success(outcome === 'autorizado' ? 'Decisión autorizada' : 'Rechazado; se generó el hito de corrección');
} catch (e) {
toast.error(e instanceof Error ? e.message : 'No se pudo registrar la decisión');
}
}
async function saveEvent() {
if (!companyId || !newEvent.title.trim()) { toast.error('El título es obligatorio'); return; }
@@ -183,8 +250,34 @@
<h1 class="flex items-center gap-2 text-2xl font-bold tracking-tight"><Ship class="h-6 w-6" /> {shipment.reference ?? `Embarque #${shipment.id}`}</h1>
<p class="mt-1 text-sm text-muted-foreground">{labelOf(SHIPMENT_STATUS, shipment.status)}{#if shipment.booking_number} · Booking {shipment.booking_number}{/if}</p>
</div>
<div class="flex flex-wrap gap-2">
{#if canClose}
<Button size="sm" variant="outline" onclick={() => (showReschedule = !showReschedule)} disabled={busy}><CalendarClock class="mr-1 h-4 w-4" /> Reprogramar salida</Button>
<Button size="sm" onclick={() => (showClose = !showClose)} disabled={busy}><Lock class="mr-1 h-4 w-4" /> Cerrar operación</Button>
{/if}
{#if canInvoice}
<Button size="sm" variant="outline" onclick={generateInvoice} disabled={busy}><Receipt class="mr-1 h-4 w-4" /> Generar factura</Button>
{/if}
</div>
</div>
{#if showReschedule}
<Card.Root><Card.Content class="grid gap-3 pt-6 sm:grid-cols-3">
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Nueva ETD</span><input type="date" class={inputCls} bind:value={rescheduleForm.etd} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Nuevo Cut Off</span><input type="datetime-local" class={inputCls} bind:value={rescheduleForm.cutoff_date} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Motivo</span><input class={inputCls} bind:value={rescheduleForm.reason} placeholder="Cut Off no alcanzado" /></label>
<div class="flex justify-end gap-2 sm:col-span-3"><Button variant="outline" size="sm" onclick={() => (showReschedule = false)}>Cancelar</Button><Button size="sm" onclick={doReschedule} disabled={busy}>Reprogramar</Button></div>
</Card.Content></Card.Root>
{/if}
{#if showClose}
<Card.Root><Card.Content class="grid gap-3 pt-6 sm:grid-cols-3">
<p class="text-sm text-muted-foreground sm:col-span-3">El cierre operativo registra los costos finales y habilita la facturación. Requiere resolver todos los puntos de decisión de la bitácora.</p>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Costo final total</span><input type="number" step="0.01" min="0" class={inputCls} bind:value={closeForm.actual_cost_total} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Moneda</span><input class={inputCls} bind:value={closeForm.cost_currency} maxlength="3" /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Notas</span><input class={inputCls} bind:value={closeForm.notes} /></label>
<div class="flex justify-end gap-2 sm:col-span-3"><Button variant="outline" size="sm" onclick={() => (showClose = false)}>Cancelar</Button><Button size="sm" onclick={closeOperation} disabled={busy}><Lock class="mr-1 h-4 w-4" /> Confirmar cierre</Button></div>
</Card.Content></Card.Root>
{/if}
<Card.Root>
<Card.Content class="pt-6">
@@ -206,11 +299,13 @@
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Destino</span><input class={inputCls} bind:value={form.destination} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">No. de Booking</span><input class={inputCls} bind:value={form.booking_number} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Cut Off</span><input type="datetime-local" class={inputCls} bind:value={form.cutoff_date} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Cita / ventana de recolección</span><input type="datetime-local" class={inputCls} bind:value={form.pickup_at} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">ETD (salida)</span><input type="date" class={inputCls} bind:value={form.etd} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">ETA (llegada)</span><input type="date" class={inputCls} bind:value={form.eta} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Buque / Vuelo</span><input class={inputCls} bind:value={form.vessel_flight} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Contenedor</span><input class={inputCls} bind:value={form.container_number} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Naviera / Aerolínea / Transportista</span><select class={inputCls} bind:value={form.carrier_supplier_id}><option value={undefined}>—</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">Transportista terrestre</span><select class={inputCls} bind:value={form.ground_carrier_supplier_id}><option value={undefined}>—</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">Agente aduanal</span><select class={inputCls} bind:value={form.customs_agent_id}><option value={undefined}>—</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">Agente en destino</span><select class={inputCls} bind:value={form.destination_agent_id}><option value={undefined}>—</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 sm:col-span-2"><span class="font-medium">Notas</span><textarea rows="2" class={inputCls} bind:value={form.notes}></textarea></label>
@@ -267,13 +362,27 @@
{:else}
<ol class="space-y-2">
{#each events as ev (ev.id)}
<li class="flex items-center gap-3 rounded-md border p-3">
<span class="flex h-6 w-6 shrink-0 items-center justify-center rounded-full text-xs {ev.status === 'completado' ? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-950/40 dark:text-emerald-400' : 'bg-muted text-muted-foreground'}">{ev.status === 'completado' ? '✓' : ev.position + 1}</span>
<li class="flex items-center gap-3 rounded-md border p-3 {ev.status === 'rechazado' ? 'border-destructive/40' : ''}">
<span class="flex h-6 w-6 shrink-0 items-center justify-center rounded-full text-xs {ev.status === 'completado' ? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-950/40 dark:text-emerald-400' : ev.status === 'rechazado' ? 'bg-destructive/10 text-destructive' : ev.kind === 'decision' ? 'bg-amber-100 text-amber-700 dark:bg-amber-950/40 dark:text-amber-400' : 'bg-muted text-muted-foreground'}">
{#if ev.status === 'completado'}{:else if ev.kind === 'decision'}?{:else}{ev.position + 1}{/if}
</span>
<div class="flex-1">
<p class="text-sm font-medium {ev.status === 'completado' ? 'line-through text-muted-foreground' : ''}">{ev.title}</p>
{#if ev.actual_date}<p class="text-xs text-muted-foreground">Completado: {formatDate(ev.actual_date)}</p>{/if}
<p class="text-sm font-medium {ev.status === 'completado' ? 'line-through text-muted-foreground' : ''}">
{ev.title}
{#if ev.kind === 'decision'}<span class="ml-1 rounded bg-amber-100 px-1.5 py-0.5 text-[10px] font-semibold uppercase text-amber-700 dark:bg-amber-950/40 dark:text-amber-400">decisión</span>{/if}
{#if ev.parent_event_id}<span class="ml-1 inline-flex items-center gap-0.5 rounded bg-muted px-1.5 py-0.5 text-[10px] font-semibold uppercase text-muted-foreground"><GitBranch class="h-2.5 w-2.5" /> corrección · intento {ev.attempt}</span>{/if}
</p>
<p class="text-xs text-muted-foreground">
{labelOf(EVENT_STATUS, ev.status)}{#if ev.outcome}{ev.outcome}{/if}{#if ev.actual_date} · {formatDate(ev.actual_date)}{/if}
</p>
{#if ev.notes}<p class="text-xs text-muted-foreground">{ev.notes}</p>{/if}
</div>
{#if ev.status !== 'completado'}<Button variant="ghost" size="sm" onclick={() => completeEvent(ev)} aria-label="Completar"><Check class="h-4 w-4 text-emerald-600" /></Button>{/if}
{#if ev.kind === 'decision' && !ev.outcome}
<Button variant="ghost" size="sm" onclick={() => decideEvent(ev, 'autorizado')} aria-label="Autorizar"><Check class="h-4 w-4 text-emerald-600" /></Button>
<Button variant="ghost" size="sm" onclick={() => decideEvent(ev, 'rechazado')} aria-label="Rechazar"><X class="h-4 w-4 text-destructive" /></Button>
{:else if ev.kind !== 'decision' && ev.status !== 'completado'}
<Button variant="ghost" size="sm" onclick={() => completeEvent(ev)} aria-label="Completar"><Check class="h-4 w-4 text-emerald-600" /></Button>
{/if}
<Button variant="ghost" size="sm" onclick={() => removeEvent(ev)} aria-label="Eliminar"><Trash2 class="h-4 w-4 text-destructive" /></Button>
</li>
{/each}