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:
@@ -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)),
|
||||
|
||||
@@ -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)}`)),
|
||||
|
||||
@@ -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)}`))
|
||||
};
|
||||
|
||||
|
||||
@@ -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' }
|
||||
];
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user