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';
|
import { api } from '$lib/api';
|
||||||
|
|
||||||
// ---------- Tipos ----------
|
// ---------- 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 type QuoteStatus = 'borrador' | 'enviada' | 'aceptada' | 'rechazada';
|
||||||
|
|
||||||
export interface ServiceRequest {
|
export interface ServiceRequest {
|
||||||
id: number;
|
id: number;
|
||||||
reference: string | null;
|
reference: string | null;
|
||||||
account_id: number | null;
|
account_id: number | null;
|
||||||
|
opportunity_id: number | null;
|
||||||
operation_type: string;
|
operation_type: string;
|
||||||
transport_mode: string | null;
|
transport_mode: string | null;
|
||||||
service_type: string | null;
|
service_type: string | null;
|
||||||
@@ -26,6 +27,8 @@ export interface ServiceRequest {
|
|||||||
required_date: string | null;
|
required_date: string | null;
|
||||||
destination_agent_id: number | null;
|
destination_agent_id: number | null;
|
||||||
requirements: string | null;
|
requirements: string | null;
|
||||||
|
first_contact_at: string | null;
|
||||||
|
first_contact_notes: string | null;
|
||||||
status: ServiceRequestStatus;
|
status: ServiceRequestStatus;
|
||||||
notes: string | null;
|
notes: string | null;
|
||||||
owner_user_id: 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)}`)),
|
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)),
|
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)),
|
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)}`))
|
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)}`, {})),
|
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)}`, {})),
|
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)}`, {})),
|
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)}`)),
|
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)}`))
|
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 = {
|
export const quoteItemsAPI = {
|
||||||
create: (data: QuoteItemInput, companyId: number) => unwrap<QuoteItem>(api.post(`/v1/crm/quote-items?${qp(companyId)}`, data)),
|
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)),
|
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';
|
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 {
|
export interface Invoice {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -21,10 +21,15 @@ export interface Invoice {
|
|||||||
total: number;
|
total: number;
|
||||||
paid_amount: number;
|
paid_amount: number;
|
||||||
balance: number;
|
balance: number;
|
||||||
|
ops_cost_total: number | null;
|
||||||
bank_info: string | null;
|
bank_info: string | null;
|
||||||
notes: string | null;
|
notes: string | null;
|
||||||
sent_at: string | null;
|
sent_at: string | null;
|
||||||
paid_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;
|
owner_user_id: string | null;
|
||||||
created_by: string | null;
|
created_by: string | null;
|
||||||
updated_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)),
|
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)}`, {})),
|
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)}`, {})),
|
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)}`, {})),
|
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)}`)),
|
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)}`)),
|
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;
|
status: ShipmentStatus;
|
||||||
booking_number: string | null;
|
booking_number: string | null;
|
||||||
carrier_supplier_id: number | null;
|
carrier_supplier_id: number | null;
|
||||||
|
ground_carrier_supplier_id: number | null;
|
||||||
customs_agent_id: number | null;
|
customs_agent_id: number | null;
|
||||||
destination_agent_id: number | null;
|
destination_agent_id: number | null;
|
||||||
cutoff_date: string | null;
|
cutoff_date: string | null;
|
||||||
|
pickup_at: string | null;
|
||||||
etd: string | null;
|
etd: string | null;
|
||||||
|
previous_etd: string | null;
|
||||||
eta: string | null;
|
eta: string | null;
|
||||||
vessel_flight: string | null;
|
vessel_flight: string | null;
|
||||||
container_number: string | null;
|
container_number: string | null;
|
||||||
notes: 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;
|
owner_user_id: string | null;
|
||||||
created_by: string | null;
|
created_by: string | null;
|
||||||
updated_by: string | null;
|
updated_by: string | null;
|
||||||
@@ -44,7 +51,11 @@ export interface ShipmentEvent {
|
|||||||
shipment_id: number;
|
shipment_id: number;
|
||||||
event_type: string | null;
|
event_type: string | null;
|
||||||
title: string;
|
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;
|
position: number;
|
||||||
planned_date: string | null;
|
planned_date: string | null;
|
||||||
actual_date: string | null;
|
actual_date: string | null;
|
||||||
@@ -98,6 +109,10 @@ export const shipmentsAPI = {
|
|||||||
createFromQuote: (quoteId: number, companyId: number) =>
|
createFromQuote: (quoteId: number, companyId: number) =>
|
||||||
unwrap<Shipment>(api.post(`/v1/ops/shipments/from-quote?${qp(companyId, { quote_id: quoteId })}`, {})),
|
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)),
|
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)}`)),
|
remove: (id: number, companyId: number) => unwrap(api.delete(`/v1/ops/shipments/${id}?${qp(companyId)}`)),
|
||||||
documents: (shipmentId: number, companyId: number) =>
|
documents: (shipmentId: number, companyId: number) =>
|
||||||
unwrap<ShipmentDocument[]>(api.get(`/v1/ops/shipments/${shipmentId}/documents?${qp(companyId)}`)),
|
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)),
|
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)),
|
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)}`, {})),
|
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)}`))
|
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[] = [
|
export const SR_STATUS: Option[] = [
|
||||||
{ value: 'nueva', label: 'Nueva' },
|
{ value: 'nueva', label: 'Nueva' },
|
||||||
|
{ value: 'contacto', label: 'Contacto realizado' },
|
||||||
{ value: 'en_analisis', label: 'En análisis' },
|
{ value: 'en_analisis', label: 'En análisis' },
|
||||||
{ value: 'cotizada', label: 'Cotizada' },
|
{ value: 'cotizada', label: 'Cotizada' },
|
||||||
{ value: 'aceptada', label: 'Aceptada' },
|
{ value: 'aceptada', label: 'Aceptada' },
|
||||||
@@ -231,11 +232,26 @@ export const DOC_KINDS: Option[] = [
|
|||||||
{ value: 'otro', label: 'Otro' }
|
{ 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 -----
|
// ----- Facturación -----
|
||||||
export const INVOICE_STATUS: Option[] = [
|
export const INVOICE_STATUS: Option[] = [
|
||||||
{ value: 'borrador', label: 'Borrador' },
|
{ value: 'borrador', label: 'Borrador' },
|
||||||
{ value: 'emitida', label: 'Emitida' },
|
{ value: 'emitida', label: 'Emitida' },
|
||||||
{ value: 'enviada', label: 'Enviada' },
|
{ value: 'enviada', label: 'Enviada' },
|
||||||
|
{ value: 'en_revision_cliente', label: 'En revisión del cliente' },
|
||||||
{ value: 'pagada', label: 'Pagada' },
|
{ value: 'pagada', label: 'Pagada' },
|
||||||
{ value: 'cancelada', label: 'Cancelada' }
|
{ value: 'cancelada', label: 'Cancelada' }
|
||||||
];
|
];
|
||||||
@@ -258,5 +274,6 @@ export const SHIPMENT_DOC_TYPES: Option[] = [
|
|||||||
{ value: 'packing_list', label: 'Packing List' },
|
{ value: 'packing_list', label: 'Packing List' },
|
||||||
{ value: 'carta_encomienda', label: 'Carta Encomienda' },
|
{ value: 'carta_encomienda', label: 'Carta Encomienda' },
|
||||||
{ value: 'carta_garantia', label: 'Carta Garantía' },
|
{ value: 'carta_garantia', label: 'Carta Garantía' },
|
||||||
|
{ value: 'certificado_permiso', label: 'Certificado / Permiso' },
|
||||||
{ value: 'otro', label: 'Otro' }
|
{ 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 {
|
function supplierName(id: number | null | undefined): string {
|
||||||
return suppliers.find((s) => s.id === id)?.name ?? '—';
|
return suppliers.find((s) => s.id === id)?.name ?? '—';
|
||||||
}
|
}
|
||||||
@@ -165,6 +179,9 @@
|
|||||||
{#if quote.status === 'aceptada'}
|
{#if quote.status === 'aceptada'}
|
||||||
<Button size="sm" onclick={release} disabled={busy}><Ship class="mr-1 h-4 w-4" /> Liberar a Operaciones</Button>
|
<Button size="sm" onclick={release} disabled={busy}><Ship class="mr-1 h-4 w-4" /> Liberar a Operaciones</Button>
|
||||||
{/if}
|
{/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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script lang="ts">
|
<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 * as Card from '$lib/components/ui/card';
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
import { companyStore } from '$lib/stores/company.svelte';
|
import { companyStore } from '$lib/stores/company.svelte';
|
||||||
@@ -8,6 +9,7 @@
|
|||||||
pipelinesAPI,
|
pipelinesAPI,
|
||||||
stagesAPI,
|
stagesAPI,
|
||||||
accountsAPI,
|
accountsAPI,
|
||||||
|
serviceRequestsAPI,
|
||||||
type Opportunity,
|
type Opportunity,
|
||||||
type OpportunityInput,
|
type OpportunityInput,
|
||||||
type Pipeline,
|
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) {
|
function onDragStart(event: DragEvent, id: number) {
|
||||||
draggingId = id;
|
draggingId = id;
|
||||||
event.dataTransfer?.setData('text/plain', String(id));
|
event.dataTransfer?.setData('text/plain', String(id));
|
||||||
@@ -248,6 +264,9 @@
|
|||||||
<span class="text-[10px] text-muted-foreground">{opp.probability}%</span>
|
<span class="text-[10px] text-muted-foreground">{opp.probability}%</span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -27,6 +27,7 @@
|
|||||||
let tab = $state('requerimientos');
|
let tab = $state('requerimientos');
|
||||||
let loading = $state(false);
|
let loading = $state(false);
|
||||||
let saving = $state(false);
|
let saving = $state(false);
|
||||||
|
let busy = $state(false);
|
||||||
let adding = $state(false);
|
let adding = $state(false);
|
||||||
let newRate = $state<RateRequestInput>({ service_request_id: 0, concept: 'flete_internacional', status: 'solicitada' });
|
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() {
|
function startAdd() {
|
||||||
newRate = { service_request_id: srId, concept: 'flete_internacional', status: 'solicitada', currency: 'USD' };
|
newRate = { service_request_id: srId, concept: 'flete_internacional', status: 'solicitada', currency: 'USD' };
|
||||||
adding = true;
|
adding = true;
|
||||||
@@ -104,10 +134,17 @@
|
|||||||
{#if loading && !sr}
|
{#if loading && !sr}
|
||||||
<p class="text-sm text-muted-foreground">Cargando…</p>
|
<p class="text-sm text-muted-foreground">Cargando…</p>
|
||||||
{:else if sr}
|
{:else if sr}
|
||||||
|
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||||
<div>
|
<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>
|
<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>
|
<p class="mt-1 text-sm text-muted-foreground">{labelOf(OPERATION_TYPES, sr.operation_type)} · {labelOf(SR_STATUS, sr.status)}</p>
|
||||||
</div>
|
</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.Root>
|
||||||
<Card.Content class="pt-6">
|
<Card.Content class="pt-6">
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<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 { page } from '$app/state';
|
||||||
import * as Card from '$lib/components/ui/card';
|
import * as Card from '$lib/components/ui/card';
|
||||||
import * as Table from '$lib/components/ui/table';
|
import * as Table from '$lib/components/ui/table';
|
||||||
@@ -80,7 +80,46 @@
|
|||||||
try {
|
try {
|
||||||
invoice = await invoicesAPI[action](invoice.id, companyId);
|
invoice = await invoicesAPI[action](invoice.id, companyId);
|
||||||
form = { ...invoice };
|
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) {
|
} catch (e) {
|
||||||
toast.error(e instanceof Error ? e.message : 'No se pudo actualizar');
|
toast.error(e instanceof Error ? e.message : 'No se pudo actualizar');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -114,6 +153,7 @@
|
|||||||
borrador: 'bg-slate-100 text-slate-600 dark:bg-slate-800 dark:text-slate-400',
|
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',
|
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',
|
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',
|
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'
|
cancelada: 'bg-red-100 text-red-700 dark:bg-red-950/40 dark:text-red-400'
|
||||||
};
|
};
|
||||||
@@ -133,11 +173,21 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="flex flex-wrap gap-2">
|
<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 === '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}
|
{#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>
|
||||||
</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">
|
<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>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>
|
<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">
|
<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 { page } from '$app/state';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import * as Card from '$lib/components/ui/card';
|
import * as Card from '$lib/components/ui/card';
|
||||||
@@ -15,7 +15,7 @@
|
|||||||
import { uploadFile, uploadUrl } from '$lib/api/uploads';
|
import { uploadFile, uploadUrl } from '$lib/api/uploads';
|
||||||
import {
|
import {
|
||||||
OPERATION_TYPES, TRANSPORT_MODES, SERVICE_TYPES, SHIPMENT_STATUS,
|
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';
|
} from '$lib/components/crm/format';
|
||||||
import { toast } from 'svelte-sonner';
|
import { toast } from 'svelte-sonner';
|
||||||
|
|
||||||
@@ -37,6 +37,13 @@
|
|||||||
let newDoc = $state<ShipmentDocumentInput>({ shipment_id: 0, doc_kind: 'master', doc_type: 'MBL' });
|
let newDoc = $state<ShipmentDocumentInput>({ shipment_id: 0, doc_kind: 'master', doc_type: 'MBL' });
|
||||||
let addingEvent = $state(false);
|
let addingEvent = $state(false);
|
||||||
let newEvent = $state<{ title: string; notes?: string }>({ title: '' });
|
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(() => {
|
$effect(() => {
|
||||||
const cid = companyId;
|
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 -----
|
// ----- Documentos -----
|
||||||
function startDoc() { newDoc = { shipment_id: shipmentId, doc_kind: 'master', doc_type: 'MBL' }; addingDoc = true; }
|
function startDoc() { newDoc = { shipment_id: shipmentId, doc_kind: 'master', doc_type: 'MBL' }; addingDoc = true; }
|
||||||
|
|
||||||
@@ -153,8 +204,24 @@
|
|||||||
}
|
}
|
||||||
async function completeEvent(ev: ShipmentEvent) {
|
async function completeEvent(ev: ShipmentEvent) {
|
||||||
if (!companyId) return;
|
if (!companyId) return;
|
||||||
|
try {
|
||||||
await shipmentEventsAPI.complete(ev.id, companyId);
|
await shipmentEventsAPI.complete(ev.id, companyId);
|
||||||
events = await shipmentsAPI.events(shipmentId, 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() {
|
async function saveEvent() {
|
||||||
if (!companyId || !newEvent.title.trim()) { toast.error('El título es obligatorio'); return; }
|
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>
|
<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>
|
<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>
|
||||||
|
<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>
|
<Button size="sm" variant="outline" onclick={generateInvoice} disabled={busy}><Receipt class="mr-1 h-4 w-4" /> Generar factura</Button>
|
||||||
|
{/if}
|
||||||
</div>
|
</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.Root>
|
||||||
<Card.Content class="pt-6">
|
<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">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">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">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">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">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">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">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">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 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"><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>
|
<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}
|
{:else}
|
||||||
<ol class="space-y-2">
|
<ol class="space-y-2">
|
||||||
{#each events as ev (ev.id)}
|
{#each events as ev (ev.id)}
|
||||||
<li class="flex items-center gap-3 rounded-md border p-3">
|
<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' : 'bg-muted text-muted-foreground'}">{ev.status === 'completado' ? '✓' : ev.position + 1}</span>
|
<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">
|
<div class="flex-1">
|
||||||
<p class="text-sm font-medium {ev.status === 'completado' ? 'line-through text-muted-foreground' : ''}">{ev.title}</p>
|
<p class="text-sm font-medium {ev.status === 'completado' ? 'line-through text-muted-foreground' : ''}">
|
||||||
{#if ev.actual_date}<p class="text-xs text-muted-foreground">Completado: {formatDate(ev.actual_date)}</p>{/if}
|
{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>
|
</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>
|
<Button variant="ghost" size="sm" onclick={() => removeEvent(ev)} aria-label="Eliminar"><Trash2 class="h-4 w-4 text-destructive" /></Button>
|
||||||
</li>
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
|
|||||||
Reference in New Issue
Block a user