feat(crm,ops): frontend de Solicitudes, Cotizaciones y Embarques (páginas con pestañas)
- Solicitudes (RFQ): alta + detalle (Requerimientos / Tarifas) - Cotizaciones: detalle con conceptos (costo/venta/margen), totales, acciones Enviar/Aceptar/Rechazar y "Liberar a Operaciones" - Embarques: alta + detalle (Datos / Documentos Master-House) - clientes API comercial + ops; sidebar CRM + grupo Operaciones - svelte-check: 0 errores de tipo en archivos del CRM/OPS Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
157
frontend/src/lib/api/crm/commercial.ts
Normal file
157
frontend/src/lib/api/crm/commercial.ts
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
/**
|
||||||
|
* Cliente API — Proceso comercial (Solicitudes/RFQ, tarifas, Cotizaciones).
|
||||||
|
*/
|
||||||
|
import { api } from '$lib/api';
|
||||||
|
|
||||||
|
// ---------- Tipos ----------
|
||||||
|
export type ServiceRequestStatus = 'nueva' | '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;
|
||||||
|
operation_type: string;
|
||||||
|
transport_mode: string | null;
|
||||||
|
service_type: string | null;
|
||||||
|
incoterm: string | null;
|
||||||
|
origin: string | null;
|
||||||
|
destination: string | null;
|
||||||
|
cargo_type: string | null;
|
||||||
|
weight: number | null;
|
||||||
|
volume: number | null;
|
||||||
|
load_type: string | null;
|
||||||
|
container_equipment: string | null;
|
||||||
|
commodity: string | null;
|
||||||
|
required_date: string | null;
|
||||||
|
destination_agent_id: number | null;
|
||||||
|
requirements: string | null;
|
||||||
|
status: ServiceRequestStatus;
|
||||||
|
notes: string | null;
|
||||||
|
owner_user_id: string | null;
|
||||||
|
created_by: string | null;
|
||||||
|
updated_by: string | null;
|
||||||
|
tenant_id: number;
|
||||||
|
company_id: number;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
export type ServiceRequestInput = Partial<Omit<ServiceRequest, 'id' | 'tenant_id' | 'company_id' | 'created_at' | 'updated_at' | 'created_by' | 'updated_by'>> & {
|
||||||
|
operation_type: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface RateRequest {
|
||||||
|
id: number;
|
||||||
|
service_request_id: number;
|
||||||
|
supplier_id: number | null;
|
||||||
|
concept: string;
|
||||||
|
description: string | null;
|
||||||
|
status: string;
|
||||||
|
rate_amount: number | null;
|
||||||
|
currency: string | null;
|
||||||
|
valid_until: string | null;
|
||||||
|
notes: string | null;
|
||||||
|
tenant_id: number;
|
||||||
|
company_id: number;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
export type RateRequestInput = Partial<Omit<RateRequest, 'id' | 'tenant_id' | 'company_id' | 'created_at' | 'updated_at'>> & {
|
||||||
|
service_request_id: number;
|
||||||
|
concept: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface Quote {
|
||||||
|
id: number;
|
||||||
|
reference: string | null;
|
||||||
|
service_request_id: number | null;
|
||||||
|
account_id: number | null;
|
||||||
|
currency: string;
|
||||||
|
status: QuoteStatus;
|
||||||
|
issue_date: string | null;
|
||||||
|
valid_until: string | null;
|
||||||
|
total_cost: number;
|
||||||
|
total_sale: number;
|
||||||
|
margin: number;
|
||||||
|
sent_at: string | null;
|
||||||
|
accepted_at: string | null;
|
||||||
|
rejected_at: string | null;
|
||||||
|
notes: string | null;
|
||||||
|
terms: string | null;
|
||||||
|
owner_user_id: string | null;
|
||||||
|
created_by: string | null;
|
||||||
|
updated_by: string | null;
|
||||||
|
tenant_id: number;
|
||||||
|
company_id: number;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
export type QuoteInput = Partial<Omit<Quote, 'id' | 'status' | 'total_cost' | 'total_sale' | 'margin' | 'sent_at' | 'accepted_at' | 'rejected_at' | 'tenant_id' | 'company_id' | 'created_at' | 'updated_at' | 'created_by' | 'updated_by'>>;
|
||||||
|
|
||||||
|
export interface QuoteItem {
|
||||||
|
id: number;
|
||||||
|
quote_id: number;
|
||||||
|
concept: string;
|
||||||
|
description: string | null;
|
||||||
|
supplier_id: number | null;
|
||||||
|
quantity: number;
|
||||||
|
unit_cost: number;
|
||||||
|
unit_sale: number;
|
||||||
|
currency: string | null;
|
||||||
|
line_cost: number;
|
||||||
|
line_sale: number;
|
||||||
|
tenant_id: number;
|
||||||
|
company_id: number;
|
||||||
|
}
|
||||||
|
export type QuoteItemInput = Partial<Omit<QuoteItem, 'id' | 'line_cost' | 'line_sale' | 'tenant_id' | 'company_id'>> & {
|
||||||
|
quote_id: number;
|
||||||
|
concept: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------- Clientes ----------
|
||||||
|
function qp(companyId: number, extra?: Record<string, string | number | undefined>) {
|
||||||
|
const qs = new URLSearchParams({ company_id: String(companyId) });
|
||||||
|
for (const [k, v] of Object.entries(extra ?? {})) if (v !== undefined && v !== '') qs.set(k, String(v));
|
||||||
|
return qs.toString();
|
||||||
|
}
|
||||||
|
async function unwrap<T>(p: Promise<{ data?: T; error?: string }>): Promise<T> {
|
||||||
|
const res = await p;
|
||||||
|
if (res.error) throw new Error(res.error);
|
||||||
|
return res.data as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const serviceRequestsAPI = {
|
||||||
|
list: (companyId: number, params?: { search?: string; status?: string; operation_type?: string; account_id?: number }) =>
|
||||||
|
unwrap<ServiceRequest[]>(api.get(`/v1/crm/service-requests?${qp(companyId, params)}`)),
|
||||||
|
get: (id: number, companyId: number) => unwrap<ServiceRequest>(api.get(`/v1/crm/service-requests/${id}?${qp(companyId)}`)),
|
||||||
|
create: (data: ServiceRequestInput, companyId: number) => unwrap<ServiceRequest>(api.post(`/v1/crm/service-requests?${qp(companyId)}`, data)),
|
||||||
|
update: (id: number, data: Partial<ServiceRequestInput>, companyId: number) => unwrap<ServiceRequest>(api.patch(`/v1/crm/service-requests/${id}?${qp(companyId)}`, data)),
|
||||||
|
remove: (id: number, companyId: number) => unwrap(api.delete(`/v1/crm/service-requests/${id}?${qp(companyId)}`))
|
||||||
|
};
|
||||||
|
|
||||||
|
export const rateRequestsAPI = {
|
||||||
|
list: (companyId: number, serviceRequestId?: number) =>
|
||||||
|
unwrap<RateRequest[]>(api.get(`/v1/crm/rate-requests?${qp(companyId, { service_request_id: serviceRequestId })}`)),
|
||||||
|
create: (data: RateRequestInput, companyId: number) => unwrap<RateRequest>(api.post(`/v1/crm/rate-requests?${qp(companyId)}`, data)),
|
||||||
|
update: (id: number, data: Partial<RateRequestInput>, companyId: number) => unwrap<RateRequest>(api.patch(`/v1/crm/rate-requests/${id}?${qp(companyId)}`, data)),
|
||||||
|
remove: (id: number, companyId: number) => unwrap(api.delete(`/v1/crm/rate-requests/${id}?${qp(companyId)}`))
|
||||||
|
};
|
||||||
|
|
||||||
|
export const quotesAPI = {
|
||||||
|
list: (companyId: number, params?: { search?: string; status?: string; account_id?: number }) =>
|
||||||
|
unwrap<Quote[]>(api.get(`/v1/crm/quotes?${qp(companyId, params)}`)),
|
||||||
|
get: (id: number, companyId: number) => unwrap<Quote>(api.get(`/v1/crm/quotes/${id}?${qp(companyId)}`)),
|
||||||
|
create: (data: QuoteInput, companyId: number) => unwrap<Quote>(api.post(`/v1/crm/quotes?${qp(companyId)}`, data)),
|
||||||
|
update: (id: number, data: Partial<QuoteInput>, companyId: number) => unwrap<Quote>(api.patch(`/v1/crm/quotes/${id}?${qp(companyId)}`, data)),
|
||||||
|
send: (id: number, companyId: number) => unwrap<Quote>(api.patch(`/v1/crm/quotes/${id}/send?${qp(companyId)}`, {})),
|
||||||
|
accept: (id: number, companyId: number) => unwrap<Quote>(api.patch(`/v1/crm/quotes/${id}/accept?${qp(companyId)}`, {})),
|
||||||
|
reject: (id: number, companyId: number) => unwrap<Quote>(api.patch(`/v1/crm/quotes/${id}/reject?${qp(companyId)}`, {})),
|
||||||
|
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)}`))
|
||||||
|
};
|
||||||
|
|
||||||
|
export const quoteItemsAPI = {
|
||||||
|
create: (data: QuoteItemInput, companyId: number) => unwrap<QuoteItem>(api.post(`/v1/crm/quote-items?${qp(companyId)}`, data)),
|
||||||
|
update: (id: number, data: Partial<QuoteItemInput>, companyId: number) => unwrap<QuoteItem>(api.patch(`/v1/crm/quote-items/${id}?${qp(companyId)}`, data)),
|
||||||
|
remove: (id: number, companyId: number) => unwrap(api.delete(`/v1/crm/quote-items/${id}?${qp(companyId)}`))
|
||||||
|
};
|
||||||
@@ -12,3 +12,4 @@ export { pipelinesAPI, stagesAPI, type StageInput } from './pipelines';
|
|||||||
export { opportunitiesAPI } from './opportunities';
|
export { opportunitiesAPI } from './opportunities';
|
||||||
export { activitiesAPI } from './activities';
|
export { activitiesAPI } from './activities';
|
||||||
export { metricsAPI } from './metrics';
|
export { metricsAPI } from './metrics';
|
||||||
|
export * from './commercial';
|
||||||
|
|||||||
90
frontend/src/lib/api/ops/index.ts
Normal file
90
frontend/src/lib/api/ops/index.ts
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
/**
|
||||||
|
* Cliente API — Operaciones (Embarques y documentos de transporte).
|
||||||
|
*/
|
||||||
|
import { api } from '$lib/api';
|
||||||
|
|
||||||
|
export type ShipmentStatus =
|
||||||
|
| 'abierta' | 'booking' | 'en_transito' | 'arribado' | 'entregada' | 'cerrada' | 'cancelada';
|
||||||
|
|
||||||
|
export interface Shipment {
|
||||||
|
id: number;
|
||||||
|
reference: string | null;
|
||||||
|
quote_id: number | null;
|
||||||
|
service_request_id: number | null;
|
||||||
|
account_id: number | null;
|
||||||
|
operation_type: string | null;
|
||||||
|
transport_mode: string | null;
|
||||||
|
service_type: string | null;
|
||||||
|
incoterm: string | null;
|
||||||
|
origin: string | null;
|
||||||
|
destination: string | null;
|
||||||
|
status: ShipmentStatus;
|
||||||
|
booking_number: string | null;
|
||||||
|
carrier_supplier_id: number | null;
|
||||||
|
customs_agent_id: number | null;
|
||||||
|
destination_agent_id: number | null;
|
||||||
|
cutoff_date: string | null;
|
||||||
|
etd: string | null;
|
||||||
|
eta: string | null;
|
||||||
|
vessel_flight: string | null;
|
||||||
|
container_number: string | null;
|
||||||
|
notes: string | null;
|
||||||
|
owner_user_id: string | null;
|
||||||
|
created_by: string | null;
|
||||||
|
updated_by: string | null;
|
||||||
|
tenant_id: number;
|
||||||
|
company_id: number;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
export type ShipmentInput = Partial<Omit<Shipment, 'id' | 'tenant_id' | 'company_id' | 'created_at' | 'updated_at' | 'created_by' | 'updated_by'>>;
|
||||||
|
|
||||||
|
export interface ShipmentDocument {
|
||||||
|
id: number;
|
||||||
|
shipment_id: number;
|
||||||
|
doc_kind: string;
|
||||||
|
doc_type: string;
|
||||||
|
number: string | null;
|
||||||
|
issue_date: string | null;
|
||||||
|
file_url: string | null;
|
||||||
|
file_key: string | null;
|
||||||
|
notes: string | null;
|
||||||
|
tenant_id: number;
|
||||||
|
company_id: number;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
export type ShipmentDocumentInput = Partial<Omit<ShipmentDocument, 'id' | 'tenant_id' | 'company_id' | 'created_at' | 'updated_at'>> & {
|
||||||
|
shipment_id: number;
|
||||||
|
doc_type: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function qp(companyId: number, extra?: Record<string, string | number | undefined>) {
|
||||||
|
const qs = new URLSearchParams({ company_id: String(companyId) });
|
||||||
|
for (const [k, v] of Object.entries(extra ?? {})) if (v !== undefined && v !== '') qs.set(k, String(v));
|
||||||
|
return qs.toString();
|
||||||
|
}
|
||||||
|
async function unwrap<T>(p: Promise<{ data?: T; error?: string }>): Promise<T> {
|
||||||
|
const res = await p;
|
||||||
|
if (res.error) throw new Error(res.error);
|
||||||
|
return res.data as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const shipmentsAPI = {
|
||||||
|
list: (companyId: number, params?: { search?: string; status?: string; account_id?: number }) =>
|
||||||
|
unwrap<Shipment[]>(api.get(`/v1/ops/shipments?${qp(companyId, params)}`)),
|
||||||
|
get: (id: number, companyId: number) => unwrap<Shipment>(api.get(`/v1/ops/shipments/${id}?${qp(companyId)}`)),
|
||||||
|
create: (data: ShipmentInput, companyId: number) => unwrap<Shipment>(api.post(`/v1/ops/shipments?${qp(companyId)}`, data)),
|
||||||
|
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)),
|
||||||
|
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)}`))
|
||||||
|
};
|
||||||
|
|
||||||
|
export const shipmentDocumentsAPI = {
|
||||||
|
create: (data: ShipmentDocumentInput, companyId: number) => unwrap<ShipmentDocument>(api.post(`/v1/ops/shipment-documents?${qp(companyId)}`, data)),
|
||||||
|
update: (id: number, data: Partial<ShipmentDocumentInput>, companyId: number) => unwrap<ShipmentDocument>(api.patch(`/v1/ops/shipment-documents/${id}?${qp(companyId)}`, data)),
|
||||||
|
remove: (id: number, companyId: number) => unwrap(api.delete(`/v1/ops/shipment-documents/${id}?${qp(companyId)}`))
|
||||||
|
};
|
||||||
@@ -157,3 +157,89 @@ export const ACTIVITY_STATUS: Option[] = [
|
|||||||
{ value: 'completed', label: 'Completada' },
|
{ value: 'completed', label: 'Completada' },
|
||||||
{ value: 'canceled', label: 'Cancelada' }
|
{ value: 'canceled', label: 'Cancelada' }
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// ----- Comercial: Solicitudes / Cotizaciones -----
|
||||||
|
export const OPERATION_TYPES: Option[] = [
|
||||||
|
{ value: 'importacion', label: 'Importación' },
|
||||||
|
{ value: 'exportacion', label: 'Exportación' }
|
||||||
|
];
|
||||||
|
|
||||||
|
export const TRANSPORT_MODES: Option[] = [
|
||||||
|
{ value: 'maritimo', label: 'Marítimo' },
|
||||||
|
{ value: 'aereo', label: 'Aéreo' },
|
||||||
|
{ value: 'terrestre', label: 'Terrestre' },
|
||||||
|
{ value: 'ferroviario', label: 'Ferroviario' },
|
||||||
|
{ value: 'multimodal', label: 'Multimodal' }
|
||||||
|
];
|
||||||
|
|
||||||
|
export const SERVICE_TYPES: Option[] = [
|
||||||
|
{ value: 'puerto_puerto', label: 'Puerto – Puerto' },
|
||||||
|
{ value: 'puerto_puerta', label: 'Puerto – Puerta' },
|
||||||
|
{ value: 'puerta_puerto', label: 'Puerta – Puerto' },
|
||||||
|
{ value: 'puerta_puerta', label: 'Puerta – Puerta (Door to Door)' }
|
||||||
|
];
|
||||||
|
|
||||||
|
export const LOAD_TYPES: Option[] = [
|
||||||
|
{ value: 'FCL', label: 'FCL (contenedor completo)' },
|
||||||
|
{ value: 'LCL', label: 'LCL (carga consolidada)' }
|
||||||
|
];
|
||||||
|
|
||||||
|
export const SR_STATUS: Option[] = [
|
||||||
|
{ value: 'nueva', label: 'Nueva' },
|
||||||
|
{ value: 'en_analisis', label: 'En análisis' },
|
||||||
|
{ value: 'cotizada', label: 'Cotizada' },
|
||||||
|
{ value: 'aceptada', label: 'Aceptada' },
|
||||||
|
{ value: 'rechazada', label: 'Rechazada' },
|
||||||
|
{ value: 'liberada', label: 'Liberada a operaciones' }
|
||||||
|
];
|
||||||
|
|
||||||
|
export const QUOTE_STATUS: Option[] = [
|
||||||
|
{ value: 'borrador', label: 'Borrador' },
|
||||||
|
{ value: 'enviada', label: 'Enviada' },
|
||||||
|
{ value: 'aceptada', label: 'Aceptada' },
|
||||||
|
{ value: 'rechazada', label: 'Rechazada' }
|
||||||
|
];
|
||||||
|
|
||||||
|
export const QUOTE_CONCEPTS: Option[] = [
|
||||||
|
{ value: 'flete_internacional', label: 'Flete internacional' },
|
||||||
|
{ value: 'transporte_terrestre', label: 'Transporte terrestre' },
|
||||||
|
{ value: 'despacho_aduanal', label: 'Despacho aduanal' },
|
||||||
|
{ value: 'gastos_destino', label: 'Gastos en destino' },
|
||||||
|
{ value: 'otros', label: 'Otros cargos' }
|
||||||
|
];
|
||||||
|
|
||||||
|
export const RATE_STATUS: Option[] = [
|
||||||
|
{ value: 'solicitada', label: 'Solicitada' },
|
||||||
|
{ value: 'recibida', label: 'Recibida' },
|
||||||
|
{ value: 'declinada', label: 'Declinada' }
|
||||||
|
];
|
||||||
|
|
||||||
|
// ----- Operaciones: Embarques -----
|
||||||
|
export const SHIPMENT_STATUS: Option[] = [
|
||||||
|
{ value: 'abierta', label: 'Abierta' },
|
||||||
|
{ value: 'booking', label: 'Booking' },
|
||||||
|
{ value: 'en_transito', label: 'En tránsito' },
|
||||||
|
{ value: 'arribado', label: 'Arribado' },
|
||||||
|
{ value: 'entregada', label: 'Entregada' },
|
||||||
|
{ value: 'cerrada', label: 'Cerrada' },
|
||||||
|
{ value: 'cancelada', label: 'Cancelada' }
|
||||||
|
];
|
||||||
|
|
||||||
|
export const DOC_KINDS: Option[] = [
|
||||||
|
{ value: 'master', label: 'Master' },
|
||||||
|
{ value: 'house', label: 'House' },
|
||||||
|
{ value: 'otro', label: 'Otro' }
|
||||||
|
];
|
||||||
|
|
||||||
|
export const SHIPMENT_DOC_TYPES: Option[] = [
|
||||||
|
{ value: 'MBL', label: 'MBL (Master Bill of Lading)' },
|
||||||
|
{ value: 'HBL', label: 'HBL (House Bill of Lading)' },
|
||||||
|
{ value: 'MAWB', label: 'MAWB (Master Air Waybill)' },
|
||||||
|
{ value: 'HAWB', label: 'HAWB (House Air Waybill)' },
|
||||||
|
{ value: 'CMR', label: 'CMR (Carta Porte Internacional)' },
|
||||||
|
{ value: 'factura_comercial', label: 'Factura Comercial' },
|
||||||
|
{ value: 'packing_list', label: 'Packing List' },
|
||||||
|
{ value: 'carta_encomienda', label: 'Carta Encomienda' },
|
||||||
|
{ value: 'carta_garantia', label: 'Carta Garantía' },
|
||||||
|
{ value: 'otro', label: 'Otro' }
|
||||||
|
];
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
Users,
|
Users,
|
||||||
Shield,
|
Shield,
|
||||||
Briefcase,
|
Briefcase,
|
||||||
|
Ship,
|
||||||
} from '@lucide/svelte';
|
} from '@lucide/svelte';
|
||||||
|
|
||||||
export type SystemContext = 'fixed_asset' | 'inventory';
|
export type SystemContext = 'fixed_asset' | 'inventory';
|
||||||
@@ -44,11 +45,21 @@ export function getNavMain(): NavMainItem[] {
|
|||||||
{ title: 'Clientes / Prospectos', url: '/dashboard/crm/cuentas' },
|
{ title: 'Clientes / Prospectos', url: '/dashboard/crm/cuentas' },
|
||||||
{ title: 'Proveedores', url: '/dashboard/crm/proveedores' },
|
{ title: 'Proveedores', url: '/dashboard/crm/proveedores' },
|
||||||
{ title: 'Contactos', url: '/dashboard/crm/contactos' },
|
{ title: 'Contactos', url: '/dashboard/crm/contactos' },
|
||||||
|
{ title: 'Solicitudes', url: '/dashboard/crm/solicitudes' },
|
||||||
|
{ title: 'Cotizaciones', url: '/dashboard/crm/cotizaciones' },
|
||||||
{ title: 'Prospectos (embudo)', url: '/dashboard/crm/prospectos' },
|
{ title: 'Prospectos (embudo)', url: '/dashboard/crm/prospectos' },
|
||||||
{ title: 'Oportunidades', url: '/dashboard/crm/oportunidades' },
|
{ title: 'Oportunidades', url: '/dashboard/crm/oportunidades' },
|
||||||
{ title: 'Actividades', url: '/dashboard/crm/actividades' },
|
{ title: 'Actividades', url: '/dashboard/crm/actividades' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: 'Operaciones',
|
||||||
|
url: '/dashboard/ops/embarques',
|
||||||
|
icon: Ship,
|
||||||
|
items: [
|
||||||
|
{ title: 'Embarques', url: '/dashboard/ops/embarques' },
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: 'Usuarios',
|
title: 'Usuarios',
|
||||||
url: '/dashboard/users',
|
url: '/dashboard/users',
|
||||||
|
|||||||
121
frontend/src/routes/dashboard/crm/cotizaciones/+page.svelte
Normal file
121
frontend/src/routes/dashboard/crm/cotizaciones/+page.svelte
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Receipt, Plus, Trash2, Search, ChevronRight } from '@lucide/svelte';
|
||||||
|
import * as Card from '$lib/components/ui/card';
|
||||||
|
import * as Table from '$lib/components/ui/table';
|
||||||
|
import { Button } from '$lib/components/ui/button';
|
||||||
|
import { companyStore } from '$lib/stores/company.svelte';
|
||||||
|
import { quotesAPI, type Quote } from '$lib/api/crm';
|
||||||
|
import { QUOTE_STATUS, labelOf, formatMoney } from '$lib/components/crm/format';
|
||||||
|
import { toast } from 'svelte-sonner';
|
||||||
|
|
||||||
|
let items = $state<Quote[]>([]);
|
||||||
|
let loading = $state(false);
|
||||||
|
let search = $state('');
|
||||||
|
let statusFilter = $state('');
|
||||||
|
|
||||||
|
const companyId = $derived(companyStore.activeCompany?.id ?? null);
|
||||||
|
const filtered = $derived(
|
||||||
|
items.filter((q) => {
|
||||||
|
if (statusFilter && q.status !== statusFilter) return false;
|
||||||
|
if (search.trim()) return `${q.reference ?? ''}`.toLowerCase().includes(search.trim().toLowerCase());
|
||||||
|
return true;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const statusClass: Record<string, string> = {
|
||||||
|
borrador: 'bg-slate-100 text-slate-600 dark:bg-slate-800 dark:text-slate-400',
|
||||||
|
enviada: 'bg-blue-100 text-blue-700 dark:bg-blue-950/40 dark:text-blue-400',
|
||||||
|
aceptada: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-950/40 dark:text-emerald-400',
|
||||||
|
rechazada: 'bg-red-100 text-red-700 dark:bg-red-950/40 dark:text-red-400'
|
||||||
|
};
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const cid = companyId;
|
||||||
|
if (!cid) return;
|
||||||
|
void load(cid);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function load(cid: number) {
|
||||||
|
loading = true;
|
||||||
|
try {
|
||||||
|
items = await quotesAPI.list(cid);
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof Error ? e.message : 'No se pudieron cargar las cotizaciones');
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove(q: Quote) {
|
||||||
|
if (!companyId || !confirm(`¿Eliminar la cotización ${q.reference ?? q.id}?`)) return;
|
||||||
|
try {
|
||||||
|
await quotesAPI.remove(q.id, companyId);
|
||||||
|
toast.success('Cotización eliminada');
|
||||||
|
await load(companyId);
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof Error ? e.message : 'No se pudo eliminar');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputCls = 'rounded-md border bg-transparent px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h1 class="flex items-center gap-2 text-2xl font-bold tracking-tight"><Receipt class="h-6 w-6" /> Cotizaciones</h1>
|
||||||
|
<p class="mt-1 text-sm text-muted-foreground">Propuestas económicas con conceptos de costo y venta.</p>
|
||||||
|
</div>
|
||||||
|
<Button href="/dashboard/crm/cotizaciones/nuevo" disabled={!companyId}><Plus class="mr-1 h-4 w-4" /> Nueva cotización</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card.Root>
|
||||||
|
<Card.Header>
|
||||||
|
<div class="flex flex-wrap items-center gap-3">
|
||||||
|
<div class="relative max-w-sm flex-1">
|
||||||
|
<Search class="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||||
|
<input class="w-full py-2 pl-8 pr-3 {inputCls}" placeholder="Buscar por folio…" bind:value={search} />
|
||||||
|
</div>
|
||||||
|
<select class={inputCls} bind:value={statusFilter}>
|
||||||
|
<option value="">Todos los estatus</option>
|
||||||
|
{#each QUOTE_STATUS as s (s.value)}<option value={s.value}>{s.label}</option>{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</Card.Header>
|
||||||
|
<Card.Content>
|
||||||
|
{#if loading}
|
||||||
|
<p class="py-6 text-center text-sm text-muted-foreground">Cargando…</p>
|
||||||
|
{:else if filtered.length === 0}
|
||||||
|
<p class="py-6 text-center text-sm text-muted-foreground">Sin cotizaciones.</p>
|
||||||
|
{:else}
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<Table.Root>
|
||||||
|
<Table.Header>
|
||||||
|
<Table.Row>
|
||||||
|
<Table.Head>Folio</Table.Head>
|
||||||
|
<Table.Head>Estatus</Table.Head>
|
||||||
|
<Table.Head class="text-right">Total venta</Table.Head>
|
||||||
|
<Table.Head class="text-right">Margen</Table.Head>
|
||||||
|
<Table.Head class="text-right">Acciones</Table.Head>
|
||||||
|
</Table.Row>
|
||||||
|
</Table.Header>
|
||||||
|
<Table.Body>
|
||||||
|
{#each filtered as q (q.id)}
|
||||||
|
<Table.Row>
|
||||||
|
<Table.Cell class="font-medium"><a class="hover:underline" href={`/dashboard/crm/cotizaciones/${q.id}`}>{q.reference ?? `#${q.id}`}</a></Table.Cell>
|
||||||
|
<Table.Cell><span class="inline-flex rounded-full px-2 py-0.5 text-xs font-medium {statusClass[q.status] ?? ''}">{labelOf(QUOTE_STATUS, q.status)}</span></Table.Cell>
|
||||||
|
<Table.Cell class="text-right">{formatMoney(q.total_sale, q.currency)}</Table.Cell>
|
||||||
|
<Table.Cell class="text-right">{formatMoney(q.margin, q.currency)}</Table.Cell>
|
||||||
|
<Table.Cell class="text-right">
|
||||||
|
<Button variant="ghost" size="sm" href={`/dashboard/crm/cotizaciones/${q.id}`} aria-label="Abrir"><ChevronRight class="h-4 w-4" /></Button>
|
||||||
|
<Button variant="ghost" size="sm" onclick={() => remove(q)} aria-label="Eliminar"><Trash2 class="h-4 w-4 text-destructive" /></Button>
|
||||||
|
</Table.Cell>
|
||||||
|
</Table.Row>
|
||||||
|
{/each}
|
||||||
|
</Table.Body>
|
||||||
|
</Table.Root>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</Card.Content>
|
||||||
|
</Card.Root>
|
||||||
|
</div>
|
||||||
234
frontend/src/routes/dashboard/crm/cotizaciones/[id]/+page.svelte
Normal file
234
frontend/src/routes/dashboard/crm/cotizaciones/[id]/+page.svelte
Normal file
@@ -0,0 +1,234 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { ArrowLeft, Receipt, Plus, Trash2, Send, Check, X, Ship } from '@lucide/svelte';
|
||||||
|
import { page } from '$app/state';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import * as Card from '$lib/components/ui/card';
|
||||||
|
import * as Table from '$lib/components/ui/table';
|
||||||
|
import { Button } from '$lib/components/ui/button';
|
||||||
|
import { companyStore } from '$lib/stores/company.svelte';
|
||||||
|
import {
|
||||||
|
quotesAPI, quoteItemsAPI, accountsAPI, serviceRequestsAPI, suppliersAPI,
|
||||||
|
type Quote, type QuoteInput, type QuoteItem, type QuoteItemInput, type Account, type ServiceRequest, type Supplier
|
||||||
|
} from '$lib/api/crm';
|
||||||
|
import { shipmentsAPI } from '$lib/api/ops';
|
||||||
|
import { QUOTE_STATUS, QUOTE_CONCEPTS, labelOf, formatMoney } from '$lib/components/crm/format';
|
||||||
|
import { toast } from 'svelte-sonner';
|
||||||
|
|
||||||
|
const quoteId = $derived(Number(page.params.id));
|
||||||
|
const companyId = $derived(companyStore.activeCompany?.id ?? null);
|
||||||
|
|
||||||
|
let quote = $state<Quote | null>(null);
|
||||||
|
let items = $state<QuoteItem[]>([]);
|
||||||
|
let accounts = $state<Account[]>([]);
|
||||||
|
let requests = $state<ServiceRequest[]>([]);
|
||||||
|
let suppliers = $state<Supplier[]>([]);
|
||||||
|
let form = $state<QuoteInput>({});
|
||||||
|
let tab = $state('conceptos');
|
||||||
|
let loading = $state(false);
|
||||||
|
let saving = $state(false);
|
||||||
|
let adding = $state(false);
|
||||||
|
let busy = $state(false);
|
||||||
|
let newItem = $state<QuoteItemInput>({ quote_id: 0, concept: 'flete_internacional', quantity: 1, unit_cost: 0, unit_sale: 0 });
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const cid = companyId;
|
||||||
|
const id = quoteId;
|
||||||
|
if (!cid || !id) return;
|
||||||
|
void load(cid, id);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function load(cid: number, id: number) {
|
||||||
|
loading = true;
|
||||||
|
try {
|
||||||
|
[quote, items, accounts, requests, suppliers] = await Promise.all([
|
||||||
|
quotesAPI.get(id, cid),
|
||||||
|
quotesAPI.items(id, cid),
|
||||||
|
accountsAPI.list(cid),
|
||||||
|
serviceRequestsAPI.list(cid),
|
||||||
|
suppliersAPI.list(cid)
|
||||||
|
]);
|
||||||
|
form = { ...quote };
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof Error ? e.message : 'No se pudo cargar la cotización');
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reload() {
|
||||||
|
if (companyId) {
|
||||||
|
[quote, items] = await Promise.all([quotesAPI.get(quoteId, companyId), quotesAPI.items(quoteId, companyId)]);
|
||||||
|
form = { ...quote };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveHeader() {
|
||||||
|
if (!companyId || !quote) return;
|
||||||
|
saving = true;
|
||||||
|
try {
|
||||||
|
quote = await quotesAPI.update(quote.id, form, companyId);
|
||||||
|
form = { ...quote };
|
||||||
|
toast.success('Cambios guardados');
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof Error ? e.message : 'No se pudo guardar');
|
||||||
|
} finally {
|
||||||
|
saving = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startAdd() {
|
||||||
|
newItem = { quote_id: quoteId, concept: 'flete_internacional', quantity: 1, unit_cost: 0, unit_sale: 0, currency: quote?.currency };
|
||||||
|
adding = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveItem() {
|
||||||
|
if (!companyId) return;
|
||||||
|
try {
|
||||||
|
await quoteItemsAPI.create({ ...newItem, quote_id: quoteId }, companyId);
|
||||||
|
toast.success('Concepto agregado');
|
||||||
|
adding = false;
|
||||||
|
await reload();
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof Error ? e.message : 'No se pudo agregar');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeItem(it: QuoteItem) {
|
||||||
|
if (!companyId || !confirm('¿Eliminar concepto?')) return;
|
||||||
|
await quoteItemsAPI.remove(it.id, companyId);
|
||||||
|
await reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doAction(action: 'send' | 'accept' | 'reject') {
|
||||||
|
if (!companyId || !quote) return;
|
||||||
|
busy = true;
|
||||||
|
try {
|
||||||
|
quote = await quotesAPI[action](quote.id, companyId);
|
||||||
|
form = { ...quote };
|
||||||
|
toast.success('Cotización actualizada');
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof Error ? e.message : 'No se pudo actualizar');
|
||||||
|
} finally {
|
||||||
|
busy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function release() {
|
||||||
|
if (!companyId || !quote) return;
|
||||||
|
if (!confirm('¿Liberar esta cotización a Operaciones (crear embarque)?')) return;
|
||||||
|
busy = true;
|
||||||
|
try {
|
||||||
|
const shipment = await shipmentsAPI.createFromQuote(quote.id, companyId);
|
||||||
|
toast.success('Embarque creado');
|
||||||
|
await goto(`/dashboard/ops/embarques/${shipment.id}`);
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof Error ? e.message : 'No se pudo liberar');
|
||||||
|
} finally {
|
||||||
|
busy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function supplierName(id: number | null | undefined): string {
|
||||||
|
return suppliers.find((s) => s.id === id)?.name ?? '—';
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusClass: Record<string, string> = {
|
||||||
|
borrador: 'bg-slate-100 text-slate-600 dark:bg-slate-800 dark:text-slate-400',
|
||||||
|
enviada: 'bg-blue-100 text-blue-700 dark:bg-blue-950/40 dark:text-blue-400',
|
||||||
|
aceptada: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-950/40 dark:text-emerald-400',
|
||||||
|
rechazada: 'bg-red-100 text-red-700 dark:bg-red-950/40 dark:text-red-400'
|
||||||
|
};
|
||||||
|
const inputCls = 'rounded-md border bg-transparent px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="space-y-6">
|
||||||
|
<Button variant="ghost" size="sm" href="/dashboard/crm/cotizaciones"><ArrowLeft class="mr-1 h-4 w-4" /> Cotizaciones</Button>
|
||||||
|
|
||||||
|
{#if loading && !quote}
|
||||||
|
<p class="text-sm text-muted-foreground">Cargando…</p>
|
||||||
|
{:else if quote}
|
||||||
|
<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"><Receipt class="h-6 w-6" /> {quote.reference ?? `Cotización #${quote.id}`}</h1>
|
||||||
|
<p class="mt-1 text-sm">
|
||||||
|
<span class="inline-flex rounded-full px-2 py-0.5 text-xs font-medium {statusClass[quote.status] ?? ''}">{labelOf(QUOTE_STATUS, quote.status)}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
{#if quote.status === 'borrador'}
|
||||||
|
<Button size="sm" variant="outline" onclick={() => doAction('send')} disabled={busy}><Send class="mr-1 h-4 w-4" /> Enviar</Button>
|
||||||
|
{/if}
|
||||||
|
{#if quote.status === 'enviada'}
|
||||||
|
<Button size="sm" variant="outline" onclick={() => doAction('accept')} disabled={busy}><Check class="mr-1 h-4 w-4" /> Aceptar</Button>
|
||||||
|
<Button size="sm" variant="outline" onclick={() => doAction('reject')} disabled={busy}><X class="mr-1 h-4 w-4" /> Rechazar</Button>
|
||||||
|
{/if}
|
||||||
|
{#if quote.status === 'aceptada'}
|
||||||
|
<Button size="sm" onclick={release} disabled={busy}><Ship class="mr-1 h-4 w-4" /> Liberar a Operaciones</Button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-4 sm:grid-cols-3">
|
||||||
|
<Card.Root><Card.Header><Card.Description>Costo total</Card.Description><Card.Title class="text-xl">{formatMoney(quote.total_cost, quote.currency)}</Card.Title></Card.Header></Card.Root>
|
||||||
|
<Card.Root><Card.Header><Card.Description>Venta total</Card.Description><Card.Title class="text-xl">{formatMoney(quote.total_sale, quote.currency)}</Card.Title></Card.Header></Card.Root>
|
||||||
|
<Card.Root><Card.Header><Card.Description>Margen</Card.Description><Card.Title class="text-xl text-emerald-600">{formatMoney(quote.margin, quote.currency)}</Card.Title></Card.Header></Card.Root>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card.Root>
|
||||||
|
<Card.Content class="pt-6">
|
||||||
|
<div class="mb-5 flex flex-wrap gap-1 border-b">
|
||||||
|
{#each [{ id: 'conceptos', label: 'Conceptos' }, { id: 'datos', label: 'Datos' }] as t (t.id)}
|
||||||
|
<button type="button" class="border-b-2 px-3 py-2 text-sm font-medium transition-colors {tab === t.id ? 'border-primary text-foreground' : 'border-transparent text-muted-foreground hover:text-foreground'}" onclick={() => (tab = t.id)}>{t.label}</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if tab === 'conceptos'}
|
||||||
|
<div class="mb-3 flex justify-end"><Button size="sm" variant="outline" onclick={startAdd}><Plus class="mr-1 h-4 w-4" /> Agregar concepto</Button></div>
|
||||||
|
{#if adding}
|
||||||
|
<div class="mb-4 grid gap-3 rounded-md border p-3 sm:grid-cols-3">
|
||||||
|
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Concepto</span><select class={inputCls} bind:value={newItem.concept}>{#each QUOTE_CONCEPTS as c (c.value)}<option value={c.value}>{c.label}</option>{/each}</select></label>
|
||||||
|
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Proveedor</span><select class={inputCls} bind:value={newItem.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">Descripción</span><input class={inputCls} bind:value={newItem.description} /></label>
|
||||||
|
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Cantidad</span><input type="number" min="0" step="0.01" class={inputCls} bind:value={newItem.quantity} /></label>
|
||||||
|
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Costo unitario</span><input type="number" min="0" step="0.01" class={inputCls} bind:value={newItem.unit_cost} /></label>
|
||||||
|
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Venta unitaria</span><input type="number" min="0" step="0.01" class={inputCls} bind:value={newItem.unit_sale} /></label>
|
||||||
|
<div class="flex justify-end gap-2 sm:col-span-3"><Button variant="outline" size="sm" onclick={() => (adding = false)}>Cancelar</Button><Button size="sm" onclick={saveItem}>Guardar</Button></div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if items.length === 0}
|
||||||
|
<p class="text-sm text-muted-foreground">Sin conceptos. Agrega el flete, despacho, gastos, etc.</p>
|
||||||
|
{:else}
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<Table.Root>
|
||||||
|
<Table.Header><Table.Row><Table.Head>Concepto</Table.Head><Table.Head>Proveedor</Table.Head><Table.Head class="text-right">Cant.</Table.Head><Table.Head class="text-right">Costo</Table.Head><Table.Head class="text-right">Venta</Table.Head><Table.Head></Table.Head></Table.Row></Table.Header>
|
||||||
|
<Table.Body>
|
||||||
|
{#each items as it (it.id)}
|
||||||
|
<Table.Row>
|
||||||
|
<Table.Cell class="font-medium">{labelOf(QUOTE_CONCEPTS, it.concept)}{#if it.description}<span class="block text-xs text-muted-foreground">{it.description}</span>{/if}</Table.Cell>
|
||||||
|
<Table.Cell>{supplierName(it.supplier_id)}</Table.Cell>
|
||||||
|
<Table.Cell class="text-right">{it.quantity}</Table.Cell>
|
||||||
|
<Table.Cell class="text-right">{formatMoney(it.line_cost, quote.currency)}</Table.Cell>
|
||||||
|
<Table.Cell class="text-right">{formatMoney(it.line_sale, quote.currency)}</Table.Cell>
|
||||||
|
<Table.Cell class="text-right"><Button variant="ghost" size="sm" onclick={() => removeItem(it)} aria-label="Eliminar"><Trash2 class="h-4 w-4 text-destructive" /></Button></Table.Cell>
|
||||||
|
</Table.Row>
|
||||||
|
{/each}
|
||||||
|
</Table.Body>
|
||||||
|
</Table.Root>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{:else}
|
||||||
|
<div class="grid gap-4 sm:grid-cols-2">
|
||||||
|
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Folio</span><input class={inputCls} bind:value={form.reference} /></label>
|
||||||
|
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Cliente</span><select class={inputCls} bind:value={form.account_id}><option value={undefined}>—</option>{#each accounts as a (a.id)}<option value={a.id}>{a.name}</option>{/each}</select></label>
|
||||||
|
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Solicitud (RFQ)</span><select class={inputCls} bind:value={form.service_request_id}><option value={undefined}>—</option>{#each requests as r (r.id)}<option value={r.id}>{r.reference ?? `#${r.id}`}</option>{/each}</select></label>
|
||||||
|
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Moneda</span><input class={inputCls} maxlength="3" bind:value={form.currency} /></label>
|
||||||
|
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Vigencia</span><input type="date" class={inputCls} bind:value={form.valid_until} /></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">Términos y condiciones</span><textarea rows="2" class={inputCls} bind:value={form.terms}></textarea></label>
|
||||||
|
</div>
|
||||||
|
<div class="mt-6 flex justify-end border-t pt-4"><Button onclick={saveHeader} disabled={saving}>{saving ? 'Guardando…' : 'Guardar cambios'}</Button></div>
|
||||||
|
{/if}
|
||||||
|
</Card.Content>
|
||||||
|
</Card.Root>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { ArrowLeft, Receipt } 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';
|
||||||
|
import { quotesAPI, accountsAPI, serviceRequestsAPI, type QuoteInput, type Account, type ServiceRequest } from '$lib/api/crm';
|
||||||
|
import { toast } from 'svelte-sonner';
|
||||||
|
|
||||||
|
let form = $state<QuoteInput>({ currency: 'USD' });
|
||||||
|
let accounts = $state<Account[]>([]);
|
||||||
|
let requests = $state<ServiceRequest[]>([]);
|
||||||
|
let saving = $state(false);
|
||||||
|
|
||||||
|
const companyId = $derived(companyStore.activeCompany?.id ?? null);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const cid = companyId;
|
||||||
|
if (!cid) return;
|
||||||
|
void (async () => {
|
||||||
|
[accounts, requests] = await Promise.all([accountsAPI.list(cid), serviceRequestsAPI.list(cid)]);
|
||||||
|
})();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
if (!companyId) return;
|
||||||
|
saving = true;
|
||||||
|
try {
|
||||||
|
const created = await quotesAPI.create(form, companyId);
|
||||||
|
toast.success('Cotización creada');
|
||||||
|
await goto(`/dashboard/crm/cotizaciones/${created.id}`);
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof Error ? e.message : 'No se pudo crear la cotización');
|
||||||
|
} finally {
|
||||||
|
saving = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputCls = 'rounded-md border bg-transparent px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="space-y-6">
|
||||||
|
<Button variant="ghost" size="sm" href="/dashboard/crm/cotizaciones"><ArrowLeft class="mr-1 h-4 w-4" /> Cotizaciones</Button>
|
||||||
|
<h1 class="flex items-center gap-2 text-2xl font-bold tracking-tight"><Receipt class="h-6 w-6" /> Nueva cotización</h1>
|
||||||
|
|
||||||
|
<Card.Root>
|
||||||
|
<Card.Content class="pt-6">
|
||||||
|
<div class="grid gap-4 sm:grid-cols-2">
|
||||||
|
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Folio</span><input class={inputCls} bind:value={form.reference} placeholder="COT-0001" /></label>
|
||||||
|
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Cliente</span><select class={inputCls} bind:value={form.account_id}><option value={undefined}>—</option>{#each accounts as a (a.id)}<option value={a.id}>{a.name}</option>{/each}</select></label>
|
||||||
|
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Solicitud (RFQ)</span><select class={inputCls} bind:value={form.service_request_id}><option value={undefined}>—</option>{#each requests as r (r.id)}<option value={r.id}>{r.reference ?? `#${r.id}`}</option>{/each}</select></label>
|
||||||
|
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Moneda</span><input class={inputCls} maxlength="3" bind:value={form.currency} /></label>
|
||||||
|
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Vigencia</span><input type="date" class={inputCls} bind:value={form.valid_until} /></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>
|
||||||
|
</div>
|
||||||
|
<div class="mt-6 flex justify-end gap-2 border-t pt-4">
|
||||||
|
<Button variant="outline" href="/dashboard/crm/cotizaciones">Cancelar</Button>
|
||||||
|
<Button onclick={save} disabled={saving || !companyId}>{saving ? 'Guardando…' : 'Crear y agregar conceptos'}</Button>
|
||||||
|
</div>
|
||||||
|
</Card.Content>
|
||||||
|
</Card.Root>
|
||||||
|
</div>
|
||||||
119
frontend/src/routes/dashboard/crm/solicitudes/+page.svelte
Normal file
119
frontend/src/routes/dashboard/crm/solicitudes/+page.svelte
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { FileText, Plus, Trash2, Search, ChevronRight } from '@lucide/svelte';
|
||||||
|
import * as Card from '$lib/components/ui/card';
|
||||||
|
import * as Table from '$lib/components/ui/table';
|
||||||
|
import { Button } from '$lib/components/ui/button';
|
||||||
|
import { companyStore } from '$lib/stores/company.svelte';
|
||||||
|
import { serviceRequestsAPI, type ServiceRequest } from '$lib/api/crm';
|
||||||
|
import { OPERATION_TYPES, SR_STATUS, TRANSPORT_MODES, labelOf } from '$lib/components/crm/format';
|
||||||
|
import { toast } from 'svelte-sonner';
|
||||||
|
|
||||||
|
let items = $state<ServiceRequest[]>([]);
|
||||||
|
let loading = $state(false);
|
||||||
|
let search = $state('');
|
||||||
|
let statusFilter = $state('');
|
||||||
|
|
||||||
|
const companyId = $derived(companyStore.activeCompany?.id ?? null);
|
||||||
|
const filtered = $derived(
|
||||||
|
items.filter((r) => {
|
||||||
|
if (statusFilter && r.status !== statusFilter) return false;
|
||||||
|
if (search.trim()) {
|
||||||
|
const q = search.trim().toLowerCase();
|
||||||
|
return `${r.reference ?? ''} ${r.origin ?? ''} ${r.destination ?? ''}`.toLowerCase().includes(q);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const cid = companyId;
|
||||||
|
if (!cid) return;
|
||||||
|
void load(cid);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function load(cid: number) {
|
||||||
|
loading = true;
|
||||||
|
try {
|
||||||
|
items = await serviceRequestsAPI.list(cid);
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof Error ? e.message : 'No se pudieron cargar las solicitudes');
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove(r: ServiceRequest) {
|
||||||
|
if (!companyId || !confirm(`¿Eliminar la solicitud ${r.reference ?? r.id}?`)) return;
|
||||||
|
try {
|
||||||
|
await serviceRequestsAPI.remove(r.id, companyId);
|
||||||
|
toast.success('Solicitud eliminada');
|
||||||
|
await load(companyId);
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof Error ? e.message : 'No se pudo eliminar');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputCls = 'rounded-md border bg-transparent px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h1 class="flex items-center gap-2 text-2xl font-bold tracking-tight"><FileText class="h-6 w-6" /> Solicitudes de servicio</h1>
|
||||||
|
<p class="mt-1 text-sm text-muted-foreground">Levantamiento de requerimientos (RFQ) para cotizar.</p>
|
||||||
|
</div>
|
||||||
|
<Button href="/dashboard/crm/solicitudes/nuevo" disabled={!companyId}><Plus class="mr-1 h-4 w-4" /> Nueva solicitud</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card.Root>
|
||||||
|
<Card.Header>
|
||||||
|
<div class="flex flex-wrap items-center gap-3">
|
||||||
|
<div class="relative max-w-sm flex-1">
|
||||||
|
<Search class="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||||
|
<input class="w-full py-2 pl-8 pr-3 {inputCls}" placeholder="Buscar por folio o ruta…" bind:value={search} />
|
||||||
|
</div>
|
||||||
|
<select class={inputCls} bind:value={statusFilter}>
|
||||||
|
<option value="">Todos los estatus</option>
|
||||||
|
{#each SR_STATUS as s (s.value)}<option value={s.value}>{s.label}</option>{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</Card.Header>
|
||||||
|
<Card.Content>
|
||||||
|
{#if loading}
|
||||||
|
<p class="py-6 text-center text-sm text-muted-foreground">Cargando…</p>
|
||||||
|
{:else if filtered.length === 0}
|
||||||
|
<p class="py-6 text-center text-sm text-muted-foreground">Sin solicitudes.</p>
|
||||||
|
{:else}
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<Table.Root>
|
||||||
|
<Table.Header>
|
||||||
|
<Table.Row>
|
||||||
|
<Table.Head>Folio</Table.Head>
|
||||||
|
<Table.Head>Operación</Table.Head>
|
||||||
|
<Table.Head>Medio</Table.Head>
|
||||||
|
<Table.Head>Ruta</Table.Head>
|
||||||
|
<Table.Head>Estatus</Table.Head>
|
||||||
|
<Table.Head class="text-right">Acciones</Table.Head>
|
||||||
|
</Table.Row>
|
||||||
|
</Table.Header>
|
||||||
|
<Table.Body>
|
||||||
|
{#each filtered as r (r.id)}
|
||||||
|
<Table.Row>
|
||||||
|
<Table.Cell class="font-medium"><a class="hover:underline" href={`/dashboard/crm/solicitudes/${r.id}`}>{r.reference ?? `#${r.id}`}</a></Table.Cell>
|
||||||
|
<Table.Cell>{labelOf(OPERATION_TYPES, r.operation_type)}</Table.Cell>
|
||||||
|
<Table.Cell>{labelOf(TRANSPORT_MODES, r.transport_mode)}</Table.Cell>
|
||||||
|
<Table.Cell class="text-sm">{[r.origin, r.destination].filter(Boolean).join(' → ') || '—'}</Table.Cell>
|
||||||
|
<Table.Cell>{labelOf(SR_STATUS, r.status)}</Table.Cell>
|
||||||
|
<Table.Cell class="text-right">
|
||||||
|
<Button variant="ghost" size="sm" href={`/dashboard/crm/solicitudes/${r.id}`} aria-label="Abrir"><ChevronRight class="h-4 w-4" /></Button>
|
||||||
|
<Button variant="ghost" size="sm" onclick={() => remove(r)} aria-label="Eliminar"><Trash2 class="h-4 w-4 text-destructive" /></Button>
|
||||||
|
</Table.Cell>
|
||||||
|
</Table.Row>
|
||||||
|
{/each}
|
||||||
|
</Table.Body>
|
||||||
|
</Table.Root>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</Card.Content>
|
||||||
|
</Card.Root>
|
||||||
|
</div>
|
||||||
176
frontend/src/routes/dashboard/crm/solicitudes/[id]/+page.svelte
Normal file
176
frontend/src/routes/dashboard/crm/solicitudes/[id]/+page.svelte
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { ArrowLeft, FileText, Plus, Trash2 } from '@lucide/svelte';
|
||||||
|
import { page } from '$app/state';
|
||||||
|
import * as Card from '$lib/components/ui/card';
|
||||||
|
import * as Table from '$lib/components/ui/table';
|
||||||
|
import { Button } from '$lib/components/ui/button';
|
||||||
|
import { companyStore } from '$lib/stores/company.svelte';
|
||||||
|
import {
|
||||||
|
serviceRequestsAPI, rateRequestsAPI, accountsAPI, suppliersAPI,
|
||||||
|
type ServiceRequest, type ServiceRequestInput, type RateRequest, type RateRequestInput,
|
||||||
|
type Account, type Supplier
|
||||||
|
} from '$lib/api/crm';
|
||||||
|
import {
|
||||||
|
OPERATION_TYPES, TRANSPORT_MODES, SERVICE_TYPES, LOAD_TYPES, SR_STATUS,
|
||||||
|
QUOTE_CONCEPTS, RATE_STATUS, labelOf
|
||||||
|
} from '$lib/components/crm/format';
|
||||||
|
import { toast } from 'svelte-sonner';
|
||||||
|
|
||||||
|
const srId = $derived(Number(page.params.id));
|
||||||
|
const companyId = $derived(companyStore.activeCompany?.id ?? null);
|
||||||
|
|
||||||
|
let sr = $state<ServiceRequest | null>(null);
|
||||||
|
let form = $state<ServiceRequestInput>({ operation_type: 'exportacion' });
|
||||||
|
let accounts = $state<Account[]>([]);
|
||||||
|
let suppliers = $state<Supplier[]>([]);
|
||||||
|
let rates = $state<RateRequest[]>([]);
|
||||||
|
let tab = $state('requerimientos');
|
||||||
|
let loading = $state(false);
|
||||||
|
let saving = $state(false);
|
||||||
|
let adding = $state(false);
|
||||||
|
let newRate = $state<RateRequestInput>({ service_request_id: 0, concept: 'flete_internacional', status: 'solicitada' });
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const cid = companyId;
|
||||||
|
const id = srId;
|
||||||
|
if (!cid || !id) return;
|
||||||
|
void load(cid, id);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function load(cid: number, id: number) {
|
||||||
|
loading = true;
|
||||||
|
try {
|
||||||
|
[sr, accounts, suppliers, rates] = await Promise.all([
|
||||||
|
serviceRequestsAPI.get(id, cid),
|
||||||
|
accountsAPI.list(cid),
|
||||||
|
suppliersAPI.list(cid),
|
||||||
|
rateRequestsAPI.list(cid, id)
|
||||||
|
]);
|
||||||
|
form = { ...sr };
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof Error ? e.message : 'No se pudo cargar la solicitud');
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
if (!companyId || !sr) return;
|
||||||
|
saving = true;
|
||||||
|
try {
|
||||||
|
sr = await serviceRequestsAPI.update(sr.id, form, companyId);
|
||||||
|
form = { ...sr };
|
||||||
|
toast.success('Cambios guardados');
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof Error ? e.message : 'No se pudo guardar');
|
||||||
|
} finally {
|
||||||
|
saving = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startAdd() {
|
||||||
|
newRate = { service_request_id: srId, concept: 'flete_internacional', status: 'solicitada', currency: 'USD' };
|
||||||
|
adding = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveRate() {
|
||||||
|
if (!companyId) return;
|
||||||
|
try {
|
||||||
|
await rateRequestsAPI.create({ ...newRate, service_request_id: srId }, companyId);
|
||||||
|
toast.success('Tarifa agregada');
|
||||||
|
adding = false;
|
||||||
|
rates = await rateRequestsAPI.list(companyId, srId);
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof Error ? e.message : 'No se pudo agregar');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeRate(r: RateRequest) {
|
||||||
|
if (!companyId || !confirm('¿Eliminar tarifa?')) return;
|
||||||
|
await rateRequestsAPI.remove(r.id, companyId);
|
||||||
|
rates = await rateRequestsAPI.list(companyId, srId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function supplierName(id: number | null): string {
|
||||||
|
return suppliers.find((s) => s.id === id)?.name ?? '—';
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputCls = 'rounded-md border bg-transparent px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="space-y-6">
|
||||||
|
<Button variant="ghost" size="sm" href="/dashboard/crm/solicitudes"><ArrowLeft class="mr-1 h-4 w-4" /> Solicitudes</Button>
|
||||||
|
|
||||||
|
{#if loading && !sr}
|
||||||
|
<p class="text-sm text-muted-foreground">Cargando…</p>
|
||||||
|
{:else if sr}
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<Card.Root>
|
||||||
|
<Card.Content class="pt-6">
|
||||||
|
<div class="mb-5 flex flex-wrap gap-1 border-b">
|
||||||
|
{#each [{ id: 'requerimientos', label: 'Requerimientos' }, { id: 'tarifas', label: 'Tarifas' }] as t (t.id)}
|
||||||
|
<button type="button" class="border-b-2 px-3 py-2 text-sm font-medium transition-colors {tab === t.id ? 'border-primary text-foreground' : 'border-transparent text-muted-foreground hover:text-foreground'}" onclick={() => (tab = t.id)}>{t.label}</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if tab === 'requerimientos'}
|
||||||
|
<div class="grid gap-4 sm:grid-cols-2">
|
||||||
|
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Folio</span><input class={inputCls} bind:value={form.reference} /></label>
|
||||||
|
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Cliente</span><select class={inputCls} bind:value={form.account_id}><option value={undefined}>—</option>{#each accounts as a (a.id)}<option value={a.id}>{a.name}</option>{/each}</select></label>
|
||||||
|
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Tipo de operación</span><select class={inputCls} bind:value={form.operation_type}>{#each OPERATION_TYPES as o (o.value)}<option value={o.value}>{o.label}</option>{/each}</select></label>
|
||||||
|
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Medio de transporte</span><select class={inputCls} bind:value={form.transport_mode}><option value={undefined}>—</option>{#each TRANSPORT_MODES as m (m.value)}<option value={m.value}>{m.label}</option>{/each}</select></label>
|
||||||
|
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Tipo de servicio</span><select class={inputCls} bind:value={form.service_type}><option value={undefined}>—</option>{#each SERVICE_TYPES as s (s.value)}<option value={s.value}>{s.label}</option>{/each}</select></label>
|
||||||
|
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Incoterm</span><input class={inputCls} maxlength="10" bind:value={form.incoterm} /></label>
|
||||||
|
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Origen</span><input class={inputCls} bind:value={form.origin} /></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">Tipo de carga</span><input class={inputCls} bind:value={form.cargo_type} /></label>
|
||||||
|
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Modalidad</span><select class={inputCls} bind:value={form.load_type}><option value={undefined}>—</option>{#each LOAD_TYPES as l (l.value)}<option value={l.value}>{l.label}</option>{/each}</select></label>
|
||||||
|
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Peso (kg)</span><input type="number" min="0" step="0.001" class={inputCls} bind:value={form.weight} /></label>
|
||||||
|
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Volumen (m³)</span><input type="number" min="0" step="0.001" class={inputCls} bind:value={form.volume} /></label>
|
||||||
|
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Contenedor / Equipo</span><input class={inputCls} bind:value={form.container_equipment} /></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">Estatus</span><select class={inputCls} bind:value={form.status}>{#each SR_STATUS as s (s.value)}<option value={s.value}>{s.label}</option>{/each}</select></label>
|
||||||
|
<label class="flex flex-col gap-1 text-sm sm:col-span-2"><span class="font-medium">Mercancía</span><textarea rows="2" class={inputCls} bind:value={form.commodity}></textarea></label>
|
||||||
|
<label class="flex flex-col gap-1 text-sm sm:col-span-2"><span class="font-medium">Otros requerimientos</span><textarea rows="2" class={inputCls} bind:value={form.requirements}></textarea></label>
|
||||||
|
</div>
|
||||||
|
<div class="mt-6 flex justify-end border-t pt-4"><Button onclick={save} disabled={saving}>{saving ? 'Guardando…' : 'Guardar cambios'}</Button></div>
|
||||||
|
{:else}
|
||||||
|
<div class="mb-3 flex justify-end"><Button size="sm" variant="outline" onclick={startAdd}><Plus class="mr-1 h-4 w-4" /> Agregar tarifa</Button></div>
|
||||||
|
{#if adding}
|
||||||
|
<div class="mb-4 grid gap-3 rounded-md border p-3 sm:grid-cols-2">
|
||||||
|
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Concepto</span><select class={inputCls} bind:value={newRate.concept}>{#each QUOTE_CONCEPTS as c (c.value)}<option value={c.value}>{c.label}</option>{/each}</select></label>
|
||||||
|
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Proveedor</span><select class={inputCls} bind:value={newRate.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">Tarifa</span><input type="number" min="0" step="0.01" class={inputCls} bind:value={newRate.rate_amount} /></label>
|
||||||
|
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Moneda</span><input class={inputCls} maxlength="3" bind:value={newRate.currency} /></label>
|
||||||
|
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Estatus</span><select class={inputCls} bind:value={newRate.status}>{#each RATE_STATUS as s (s.value)}<option value={s.value}>{s.label}</option>{/each}</select></label>
|
||||||
|
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Descripción</span><input class={inputCls} bind:value={newRate.description} /></label>
|
||||||
|
<div class="flex justify-end gap-2 sm:col-span-2"><Button variant="outline" size="sm" onclick={() => (adding = false)}>Cancelar</Button><Button size="sm" onclick={saveRate}>Guardar</Button></div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if rates.length === 0}
|
||||||
|
<p class="text-sm text-muted-foreground">Sin solicitudes de tarifa.</p>
|
||||||
|
{:else}
|
||||||
|
<Table.Root>
|
||||||
|
<Table.Header><Table.Row><Table.Head>Concepto</Table.Head><Table.Head>Proveedor</Table.Head><Table.Head class="text-right">Tarifa</Table.Head><Table.Head>Estatus</Table.Head><Table.Head></Table.Head></Table.Row></Table.Header>
|
||||||
|
<Table.Body>
|
||||||
|
{#each rates as r (r.id)}
|
||||||
|
<Table.Row>
|
||||||
|
<Table.Cell>{labelOf(QUOTE_CONCEPTS, r.concept)}</Table.Cell>
|
||||||
|
<Table.Cell>{supplierName(r.supplier_id)}</Table.Cell>
|
||||||
|
<Table.Cell class="text-right">{r.rate_amount != null ? `${r.rate_amount} ${r.currency ?? ''}` : '—'}</Table.Cell>
|
||||||
|
<Table.Cell>{labelOf(RATE_STATUS, r.status)}</Table.Cell>
|
||||||
|
<Table.Cell class="text-right"><Button variant="ghost" size="sm" onclick={() => removeRate(r)} aria-label="Eliminar"><Trash2 class="h-4 w-4 text-destructive" /></Button></Table.Cell>
|
||||||
|
</Table.Row>
|
||||||
|
{/each}
|
||||||
|
</Table.Body>
|
||||||
|
</Table.Root>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</Card.Content>
|
||||||
|
</Card.Root>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { ArrowLeft, FileText } 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';
|
||||||
|
import { serviceRequestsAPI, accountsAPI, suppliersAPI, type ServiceRequestInput, type Account, type Supplier } from '$lib/api/crm';
|
||||||
|
import { OPERATION_TYPES, TRANSPORT_MODES, SERVICE_TYPES, LOAD_TYPES } from '$lib/components/crm/format';
|
||||||
|
import { toast } from 'svelte-sonner';
|
||||||
|
|
||||||
|
let form = $state<ServiceRequestInput>({ operation_type: 'exportacion', status: 'nueva' });
|
||||||
|
let accounts = $state<Account[]>([]);
|
||||||
|
let suppliers = $state<Supplier[]>([]);
|
||||||
|
let saving = $state(false);
|
||||||
|
|
||||||
|
const companyId = $derived(companyStore.activeCompany?.id ?? null);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const cid = companyId;
|
||||||
|
if (!cid) return;
|
||||||
|
void (async () => {
|
||||||
|
[accounts, suppliers] = await Promise.all([accountsAPI.list(cid), suppliersAPI.list(cid)]);
|
||||||
|
})();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
if (!companyId) return;
|
||||||
|
if (!form.operation_type) { toast.error('El tipo de operación es obligatorio'); return; }
|
||||||
|
saving = true;
|
||||||
|
try {
|
||||||
|
const created = await serviceRequestsAPI.create(form, companyId);
|
||||||
|
toast.success('Solicitud creada');
|
||||||
|
await goto(`/dashboard/crm/solicitudes/${created.id}`);
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof Error ? e.message : 'No se pudo crear la solicitud');
|
||||||
|
} finally {
|
||||||
|
saving = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputCls = 'rounded-md border bg-transparent px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="space-y-6">
|
||||||
|
<Button variant="ghost" size="sm" href="/dashboard/crm/solicitudes"><ArrowLeft class="mr-1 h-4 w-4" /> Solicitudes</Button>
|
||||||
|
<h1 class="flex items-center gap-2 text-2xl font-bold tracking-tight"><FileText class="h-6 w-6" /> Nueva solicitud de servicio</h1>
|
||||||
|
|
||||||
|
<Card.Root>
|
||||||
|
<Card.Content class="space-y-5 pt-6">
|
||||||
|
<fieldset class="grid gap-4 sm:grid-cols-2">
|
||||||
|
<legend class="mb-1 text-sm font-semibold text-muted-foreground">Generales</legend>
|
||||||
|
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Folio</span><input class={inputCls} bind:value={form.reference} /></label>
|
||||||
|
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Cliente</span><select class={inputCls} bind:value={form.account_id}><option value={undefined}>—</option>{#each accounts as a (a.id)}<option value={a.id}>{a.name}</option>{/each}</select></label>
|
||||||
|
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Tipo de operación *</span><select class={inputCls} bind:value={form.operation_type}>{#each OPERATION_TYPES as o (o.value)}<option value={o.value}>{o.label}</option>{/each}</select></label>
|
||||||
|
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Medio de transporte</span><select class={inputCls} bind:value={form.transport_mode}><option value={undefined}>—</option>{#each TRANSPORT_MODES as m (m.value)}<option value={m.value}>{m.label}</option>{/each}</select></label>
|
||||||
|
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Tipo de servicio</span><select class={inputCls} bind:value={form.service_type}><option value={undefined}>—</option>{#each SERVICE_TYPES as s (s.value)}<option value={s.value}>{s.label}</option>{/each}</select></label>
|
||||||
|
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Incoterm</span><input class={inputCls} maxlength="10" bind:value={form.incoterm} placeholder="FOB, CIF…" /></label>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<fieldset class="grid gap-4 sm:grid-cols-2">
|
||||||
|
<legend class="mb-1 text-sm font-semibold text-muted-foreground">Logística y carga</legend>
|
||||||
|
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Origen</span><input class={inputCls} bind:value={form.origin} /></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">Tipo de carga</span><input class={inputCls} bind:value={form.cargo_type} /></label>
|
||||||
|
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Modalidad</span><select class={inputCls} bind:value={form.load_type}><option value={undefined}>—</option>{#each LOAD_TYPES as l (l.value)}<option value={l.value}>{l.label}</option>{/each}</select></label>
|
||||||
|
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Peso (kg)</span><input type="number" min="0" step="0.001" class={inputCls} bind:value={form.weight} /></label>
|
||||||
|
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Volumen (m³)</span><input type="number" min="0" step="0.001" class={inputCls} bind:value={form.volume} /></label>
|
||||||
|
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Contenedor / Equipo</span><input class={inputCls} bind:value={form.container_equipment} placeholder="1x40'HC" /></label>
|
||||||
|
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Fecha requerida</span><input type="date" class={inputCls} bind:value={form.required_date} /></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">Mercancía</span><textarea rows="2" class={inputCls} bind:value={form.commodity}></textarea></label>
|
||||||
|
<label class="flex flex-col gap-1 text-sm sm:col-span-2"><span class="font-medium">Otros requerimientos</span><textarea rows="2" class={inputCls} bind:value={form.requirements}></textarea></label>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<div class="flex justify-end gap-2 border-t pt-4">
|
||||||
|
<Button variant="outline" href="/dashboard/crm/solicitudes">Cancelar</Button>
|
||||||
|
<Button onclick={save} disabled={saving || !companyId}>{saving ? 'Guardando…' : 'Crear'}</Button>
|
||||||
|
</div>
|
||||||
|
</Card.Content>
|
||||||
|
</Card.Root>
|
||||||
|
</div>
|
||||||
129
frontend/src/routes/dashboard/ops/embarques/+page.svelte
Normal file
129
frontend/src/routes/dashboard/ops/embarques/+page.svelte
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Ship, Plus, Trash2, Search, ChevronRight } from '@lucide/svelte';
|
||||||
|
import * as Card from '$lib/components/ui/card';
|
||||||
|
import * as Table from '$lib/components/ui/table';
|
||||||
|
import { Button } from '$lib/components/ui/button';
|
||||||
|
import { companyStore } from '$lib/stores/company.svelte';
|
||||||
|
import { shipmentsAPI, type Shipment } from '$lib/api/ops';
|
||||||
|
import { SHIPMENT_STATUS, OPERATION_TYPES, TRANSPORT_MODES, labelOf, formatDate } from '$lib/components/crm/format';
|
||||||
|
import { toast } from 'svelte-sonner';
|
||||||
|
|
||||||
|
let items = $state<Shipment[]>([]);
|
||||||
|
let loading = $state(false);
|
||||||
|
let search = $state('');
|
||||||
|
let statusFilter = $state('');
|
||||||
|
|
||||||
|
const companyId = $derived(companyStore.activeCompany?.id ?? null);
|
||||||
|
const filtered = $derived(
|
||||||
|
items.filter((s) => {
|
||||||
|
if (statusFilter && s.status !== statusFilter) return false;
|
||||||
|
if (search.trim()) {
|
||||||
|
const q = search.trim().toLowerCase();
|
||||||
|
return `${s.reference ?? ''} ${s.booking_number ?? ''} ${s.origin ?? ''} ${s.destination ?? ''}`.toLowerCase().includes(q);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const statusClass: Record<string, string> = {
|
||||||
|
abierta: 'bg-slate-100 text-slate-600 dark:bg-slate-800 dark:text-slate-400',
|
||||||
|
booking: 'bg-blue-100 text-blue-700 dark:bg-blue-950/40 dark:text-blue-400',
|
||||||
|
en_transito: 'bg-amber-100 text-amber-700 dark:bg-amber-950/40 dark:text-amber-400',
|
||||||
|
arribado: 'bg-cyan-100 text-cyan-700 dark:bg-cyan-950/40 dark:text-cyan-400',
|
||||||
|
entregada: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-950/40 dark:text-emerald-400',
|
||||||
|
cerrada: 'bg-slate-200 text-slate-700 dark:bg-slate-700 dark:text-slate-300',
|
||||||
|
cancelada: 'bg-red-100 text-red-700 dark:bg-red-950/40 dark:text-red-400'
|
||||||
|
};
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const cid = companyId;
|
||||||
|
if (!cid) return;
|
||||||
|
void load(cid);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function load(cid: number) {
|
||||||
|
loading = true;
|
||||||
|
try {
|
||||||
|
items = await shipmentsAPI.list(cid);
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof Error ? e.message : 'No se pudieron cargar los embarques');
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove(s: Shipment) {
|
||||||
|
if (!companyId || !confirm(`¿Eliminar el embarque ${s.reference ?? s.id}?`)) return;
|
||||||
|
try {
|
||||||
|
await shipmentsAPI.remove(s.id, companyId);
|
||||||
|
toast.success('Embarque eliminado');
|
||||||
|
await load(companyId);
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof Error ? e.message : 'No se pudo eliminar');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputCls = 'rounded-md border bg-transparent px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h1 class="flex items-center gap-2 text-2xl font-bold tracking-tight"><Ship class="h-6 w-6" /> Embarques</h1>
|
||||||
|
<p class="mt-1 text-sm text-muted-foreground">Operaciones logísticas: booking, Cut Off, documentos y seguimiento.</p>
|
||||||
|
</div>
|
||||||
|
<Button href="/dashboard/ops/embarques/nuevo" disabled={!companyId}><Plus class="mr-1 h-4 w-4" /> Nuevo embarque</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card.Root>
|
||||||
|
<Card.Header>
|
||||||
|
<div class="flex flex-wrap items-center gap-3">
|
||||||
|
<div class="relative max-w-sm flex-1">
|
||||||
|
<Search class="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||||
|
<input class="w-full py-2 pl-8 pr-3 {inputCls}" placeholder="Buscar por folio, booking o ruta…" bind:value={search} />
|
||||||
|
</div>
|
||||||
|
<select class={inputCls} bind:value={statusFilter}>
|
||||||
|
<option value="">Todos los estatus</option>
|
||||||
|
{#each SHIPMENT_STATUS as s (s.value)}<option value={s.value}>{s.label}</option>{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</Card.Header>
|
||||||
|
<Card.Content>
|
||||||
|
{#if loading}
|
||||||
|
<p class="py-6 text-center text-sm text-muted-foreground">Cargando…</p>
|
||||||
|
{:else if filtered.length === 0}
|
||||||
|
<p class="py-6 text-center text-sm text-muted-foreground">Sin embarques.</p>
|
||||||
|
{:else}
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<Table.Root>
|
||||||
|
<Table.Header>
|
||||||
|
<Table.Row>
|
||||||
|
<Table.Head>Folio</Table.Head>
|
||||||
|
<Table.Head>Operación</Table.Head>
|
||||||
|
<Table.Head>Ruta</Table.Head>
|
||||||
|
<Table.Head>ETD / ETA</Table.Head>
|
||||||
|
<Table.Head>Estatus</Table.Head>
|
||||||
|
<Table.Head class="text-right">Acciones</Table.Head>
|
||||||
|
</Table.Row>
|
||||||
|
</Table.Header>
|
||||||
|
<Table.Body>
|
||||||
|
{#each filtered as s (s.id)}
|
||||||
|
<Table.Row>
|
||||||
|
<Table.Cell class="font-medium"><a class="hover:underline" href={`/dashboard/ops/embarques/${s.id}`}>{s.reference ?? `#${s.id}`}</a>{#if s.booking_number}<span class="block text-xs text-muted-foreground">{s.booking_number}</span>{/if}</Table.Cell>
|
||||||
|
<Table.Cell class="text-sm">{labelOf(OPERATION_TYPES, s.operation_type)} · {labelOf(TRANSPORT_MODES, s.transport_mode)}</Table.Cell>
|
||||||
|
<Table.Cell class="text-sm">{[s.origin, s.destination].filter(Boolean).join(' → ') || '—'}</Table.Cell>
|
||||||
|
<Table.Cell class="text-sm">{formatDate(s.etd)} / {formatDate(s.eta)}</Table.Cell>
|
||||||
|
<Table.Cell><span class="inline-flex rounded-full px-2 py-0.5 text-xs font-medium {statusClass[s.status] ?? ''}">{labelOf(SHIPMENT_STATUS, s.status)}</span></Table.Cell>
|
||||||
|
<Table.Cell class="text-right">
|
||||||
|
<Button variant="ghost" size="sm" href={`/dashboard/ops/embarques/${s.id}`} aria-label="Abrir"><ChevronRight class="h-4 w-4" /></Button>
|
||||||
|
<Button variant="ghost" size="sm" onclick={() => remove(s)} aria-label="Eliminar"><Trash2 class="h-4 w-4 text-destructive" /></Button>
|
||||||
|
</Table.Cell>
|
||||||
|
</Table.Row>
|
||||||
|
{/each}
|
||||||
|
</Table.Body>
|
||||||
|
</Table.Root>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</Card.Content>
|
||||||
|
</Card.Root>
|
||||||
|
</div>
|
||||||
173
frontend/src/routes/dashboard/ops/embarques/[id]/+page.svelte
Normal file
173
frontend/src/routes/dashboard/ops/embarques/[id]/+page.svelte
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { ArrowLeft, Ship, Plus, Trash2 } from '@lucide/svelte';
|
||||||
|
import { page } from '$app/state';
|
||||||
|
import * as Card from '$lib/components/ui/card';
|
||||||
|
import * as Table from '$lib/components/ui/table';
|
||||||
|
import { Button } from '$lib/components/ui/button';
|
||||||
|
import { companyStore } from '$lib/stores/company.svelte';
|
||||||
|
import { accountsAPI, suppliersAPI, type Account, type Supplier } from '$lib/api/crm';
|
||||||
|
import {
|
||||||
|
shipmentsAPI, shipmentDocumentsAPI,
|
||||||
|
type Shipment, type ShipmentInput, type ShipmentDocument, type ShipmentDocumentInput
|
||||||
|
} from '$lib/api/ops';
|
||||||
|
import {
|
||||||
|
OPERATION_TYPES, TRANSPORT_MODES, SERVICE_TYPES, SHIPMENT_STATUS,
|
||||||
|
DOC_KINDS, SHIPMENT_DOC_TYPES, labelOf, formatDate
|
||||||
|
} from '$lib/components/crm/format';
|
||||||
|
import { toast } from 'svelte-sonner';
|
||||||
|
|
||||||
|
const shipmentId = $derived(Number(page.params.id));
|
||||||
|
const companyId = $derived(companyStore.activeCompany?.id ?? null);
|
||||||
|
|
||||||
|
let shipment = $state<Shipment | null>(null);
|
||||||
|
let form = $state<ShipmentInput>({});
|
||||||
|
let accounts = $state<Account[]>([]);
|
||||||
|
let suppliers = $state<Supplier[]>([]);
|
||||||
|
let docs = $state<ShipmentDocument[]>([]);
|
||||||
|
let tab = $state('datos');
|
||||||
|
let loading = $state(false);
|
||||||
|
let saving = $state(false);
|
||||||
|
let adding = $state(false);
|
||||||
|
let newDoc = $state<ShipmentDocumentInput>({ shipment_id: 0, doc_kind: 'master', doc_type: 'MBL' });
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const cid = companyId;
|
||||||
|
const id = shipmentId;
|
||||||
|
if (!cid || !id) return;
|
||||||
|
void load(cid, id);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function load(cid: number, id: number) {
|
||||||
|
loading = true;
|
||||||
|
try {
|
||||||
|
[shipment, accounts, suppliers, docs] = await Promise.all([
|
||||||
|
shipmentsAPI.get(id, cid),
|
||||||
|
accountsAPI.list(cid),
|
||||||
|
suppliersAPI.list(cid),
|
||||||
|
shipmentsAPI.documents(id, cid)
|
||||||
|
]);
|
||||||
|
form = { ...shipment };
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof Error ? e.message : 'No se pudo cargar el embarque');
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
if (!companyId || !shipment) return;
|
||||||
|
saving = true;
|
||||||
|
try {
|
||||||
|
shipment = await shipmentsAPI.update(shipment.id, form, companyId);
|
||||||
|
form = { ...shipment };
|
||||||
|
toast.success('Cambios guardados');
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof Error ? e.message : 'No se pudo guardar');
|
||||||
|
} finally {
|
||||||
|
saving = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startAdd() {
|
||||||
|
newDoc = { shipment_id: shipmentId, doc_kind: 'master', doc_type: 'MBL' };
|
||||||
|
adding = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveDoc() {
|
||||||
|
if (!companyId) return;
|
||||||
|
try {
|
||||||
|
await shipmentDocumentsAPI.create({ ...newDoc, shipment_id: shipmentId }, companyId);
|
||||||
|
toast.success('Documento agregado');
|
||||||
|
adding = false;
|
||||||
|
docs = await shipmentsAPI.documents(shipmentId, companyId);
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof Error ? e.message : 'No se pudo agregar');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeDoc(d: ShipmentDocument) {
|
||||||
|
if (!companyId || !confirm('¿Eliminar documento?')) return;
|
||||||
|
await shipmentDocumentsAPI.remove(d.id, companyId);
|
||||||
|
docs = await shipmentsAPI.documents(shipmentId, companyId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputCls = 'rounded-md border bg-transparent px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="space-y-6">
|
||||||
|
<Button variant="ghost" size="sm" href="/dashboard/ops/embarques"><ArrowLeft class="mr-1 h-4 w-4" /> Embarques</Button>
|
||||||
|
|
||||||
|
{#if loading && !shipment}
|
||||||
|
<p class="text-sm text-muted-foreground">Cargando…</p>
|
||||||
|
{:else if shipment}
|
||||||
|
<div>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<Card.Root>
|
||||||
|
<Card.Content class="pt-6">
|
||||||
|
<div class="mb-5 flex flex-wrap gap-1 border-b">
|
||||||
|
{#each [{ id: 'datos', label: 'Datos del embarque' }, { id: 'documentos', label: 'Documentos' }] as t (t.id)}
|
||||||
|
<button type="button" class="border-b-2 px-3 py-2 text-sm font-medium transition-colors {tab === t.id ? 'border-primary text-foreground' : 'border-transparent text-muted-foreground hover:text-foreground'}" onclick={() => (tab = t.id)}>{t.label}</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if tab === 'datos'}
|
||||||
|
<div class="grid gap-4 sm:grid-cols-2">
|
||||||
|
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Folio</span><input class={inputCls} bind:value={form.reference} /></label>
|
||||||
|
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Cliente</span><select class={inputCls} bind:value={form.account_id}><option value={undefined}>—</option>{#each accounts as a (a.id)}<option value={a.id}>{a.name}</option>{/each}</select></label>
|
||||||
|
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Operación</span><select class={inputCls} bind:value={form.operation_type}><option value={undefined}>—</option>{#each OPERATION_TYPES as o (o.value)}<option value={o.value}>{o.label}</option>{/each}</select></label>
|
||||||
|
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Medio</span><select class={inputCls} bind:value={form.transport_mode}><option value={undefined}>—</option>{#each TRANSPORT_MODES as m (m.value)}<option value={m.value}>{m.label}</option>{/each}</select></label>
|
||||||
|
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Tipo de servicio</span><select class={inputCls} bind:value={form.service_type}><option value={undefined}>—</option>{#each SERVICE_TYPES as s (s.value)}<option value={s.value}>{s.label}</option>{/each}</select></label>
|
||||||
|
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Estatus</span><select class={inputCls} bind:value={form.status}>{#each SHIPMENT_STATUS as s (s.value)}<option value={s.value}>{s.label}</option>{/each}</select></label>
|
||||||
|
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Origen</span><input class={inputCls} bind:value={form.origin} /></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">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">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">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>
|
||||||
|
</div>
|
||||||
|
<div class="mt-6 flex justify-end border-t pt-4"><Button onclick={save} disabled={saving}>{saving ? 'Guardando…' : 'Guardar cambios'}</Button></div>
|
||||||
|
{:else}
|
||||||
|
<div class="mb-3 flex justify-end"><Button size="sm" variant="outline" onclick={startAdd}><Plus class="mr-1 h-4 w-4" /> Agregar documento</Button></div>
|
||||||
|
{#if adding}
|
||||||
|
<div class="mb-4 grid gap-3 rounded-md border p-3 sm:grid-cols-2">
|
||||||
|
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Tipo doc.</span><select class={inputCls} bind:value={newDoc.doc_kind}>{#each DOC_KINDS as k (k.value)}<option value={k.value}>{k.label}</option>{/each}</select></label>
|
||||||
|
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Documento</span><select class={inputCls} bind:value={newDoc.doc_type}>{#each SHIPMENT_DOC_TYPES as t (t.value)}<option value={t.value}>{t.label}</option>{/each}</select></label>
|
||||||
|
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Número</span><input class={inputCls} bind:value={newDoc.number} /></label>
|
||||||
|
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Fecha emisión</span><input type="date" class={inputCls} bind:value={newDoc.issue_date} /></label>
|
||||||
|
<label class="flex flex-col gap-1 text-sm sm:col-span-2"><span class="font-medium">URL del archivo</span><input class={inputCls} bind:value={newDoc.file_url} placeholder="https://…" /></label>
|
||||||
|
<div class="flex justify-end gap-2 sm:col-span-2"><Button variant="outline" size="sm" onclick={() => (adding = false)}>Cancelar</Button><Button size="sm" onclick={saveDoc}>Guardar</Button></div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if docs.length === 0}
|
||||||
|
<p class="text-sm text-muted-foreground">Sin documentos.</p>
|
||||||
|
{:else}
|
||||||
|
<Table.Root>
|
||||||
|
<Table.Header><Table.Row><Table.Head>Clase</Table.Head><Table.Head>Documento</Table.Head><Table.Head>Número</Table.Head><Table.Head>Emisión</Table.Head><Table.Head>Archivo</Table.Head><Table.Head></Table.Head></Table.Row></Table.Header>
|
||||||
|
<Table.Body>
|
||||||
|
{#each docs as d (d.id)}
|
||||||
|
<Table.Row>
|
||||||
|
<Table.Cell>{labelOf(DOC_KINDS, d.doc_kind)}</Table.Cell>
|
||||||
|
<Table.Cell class="font-medium">{labelOf(SHIPMENT_DOC_TYPES, d.doc_type)}</Table.Cell>
|
||||||
|
<Table.Cell class="font-mono text-xs">{d.number ?? '—'}</Table.Cell>
|
||||||
|
<Table.Cell>{formatDate(d.issue_date)}</Table.Cell>
|
||||||
|
<Table.Cell>{#if d.file_url}<a class="text-primary hover:underline" href={d.file_url} target="_blank" rel="noopener">Ver</a>{:else}—{/if}</Table.Cell>
|
||||||
|
<Table.Cell class="text-right"><Button variant="ghost" size="sm" onclick={() => removeDoc(d)} aria-label="Eliminar"><Trash2 class="h-4 w-4 text-destructive" /></Button></Table.Cell>
|
||||||
|
</Table.Row>
|
||||||
|
{/each}
|
||||||
|
</Table.Body>
|
||||||
|
</Table.Root>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</Card.Content>
|
||||||
|
</Card.Root>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { ArrowLeft, Ship } 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';
|
||||||
|
import { accountsAPI, type Account } from '$lib/api/crm';
|
||||||
|
import { shipmentsAPI, type ShipmentInput } from '$lib/api/ops';
|
||||||
|
import { OPERATION_TYPES, TRANSPORT_MODES, SERVICE_TYPES, SHIPMENT_STATUS } from '$lib/components/crm/format';
|
||||||
|
import { toast } from 'svelte-sonner';
|
||||||
|
|
||||||
|
let form = $state<ShipmentInput>({ status: 'abierta' });
|
||||||
|
let accounts = $state<Account[]>([]);
|
||||||
|
let saving = $state(false);
|
||||||
|
const companyId = $derived(companyStore.activeCompany?.id ?? null);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const cid = companyId;
|
||||||
|
if (!cid) return;
|
||||||
|
void (async () => { accounts = await accountsAPI.list(cid); })();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
if (!companyId) return;
|
||||||
|
saving = true;
|
||||||
|
try {
|
||||||
|
const created = await shipmentsAPI.create(form, companyId);
|
||||||
|
toast.success('Embarque creado');
|
||||||
|
await goto(`/dashboard/ops/embarques/${created.id}`);
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof Error ? e.message : 'No se pudo crear el embarque');
|
||||||
|
} finally {
|
||||||
|
saving = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputCls = 'rounded-md border bg-transparent px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="space-y-6">
|
||||||
|
<Button variant="ghost" size="sm" href="/dashboard/ops/embarques"><ArrowLeft class="mr-1 h-4 w-4" /> Embarques</Button>
|
||||||
|
<h1 class="flex items-center gap-2 text-2xl font-bold tracking-tight"><Ship class="h-6 w-6" /> Nuevo embarque</h1>
|
||||||
|
|
||||||
|
<Card.Root>
|
||||||
|
<Card.Content class="pt-6">
|
||||||
|
<div class="grid gap-4 sm:grid-cols-2">
|
||||||
|
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Folio</span><input class={inputCls} bind:value={form.reference} placeholder="EMB-0001" /></label>
|
||||||
|
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Cliente</span><select class={inputCls} bind:value={form.account_id}><option value={undefined}>—</option>{#each accounts as a (a.id)}<option value={a.id}>{a.name}</option>{/each}</select></label>
|
||||||
|
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Operación</span><select class={inputCls} bind:value={form.operation_type}><option value={undefined}>—</option>{#each OPERATION_TYPES as o (o.value)}<option value={o.value}>{o.label}</option>{/each}</select></label>
|
||||||
|
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Medio</span><select class={inputCls} bind:value={form.transport_mode}><option value={undefined}>—</option>{#each TRANSPORT_MODES as m (m.value)}<option value={m.value}>{m.label}</option>{/each}</select></label>
|
||||||
|
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Tipo de servicio</span><select class={inputCls} bind:value={form.service_type}><option value={undefined}>—</option>{#each SERVICE_TYPES as s (s.value)}<option value={s.value}>{s.label}</option>{/each}</select></label>
|
||||||
|
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Estatus</span><select class={inputCls} bind:value={form.status}>{#each SHIPMENT_STATUS as s (s.value)}<option value={s.value}>{s.label}</option>{/each}</select></label>
|
||||||
|
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Origen</span><input class={inputCls} bind:value={form.origin} /></label>
|
||||||
|
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Destino</span><input class={inputCls} bind:value={form.destination} /></label>
|
||||||
|
</div>
|
||||||
|
<div class="mt-6 flex justify-end gap-2 border-t pt-4">
|
||||||
|
<Button variant="outline" href="/dashboard/ops/embarques">Cancelar</Button>
|
||||||
|
<Button onclick={save} disabled={saving || !companyId}>{saving ? 'Guardando…' : 'Crear'}</Button>
|
||||||
|
</div>
|
||||||
|
</Card.Content>
|
||||||
|
</Card.Root>
|
||||||
|
</div>
|
||||||
Reference in New Issue
Block a user