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:
Aduanasoft
2026-07-14 18:21:25 -06:00
parent a196c44fae
commit 6f200b4505
14 changed files with 1502 additions and 0 deletions

View 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)}`))
};

View File

@@ -12,3 +12,4 @@ export { pipelinesAPI, stagesAPI, type StageInput } from './pipelines';
export { opportunitiesAPI } from './opportunities';
export { activitiesAPI } from './activities';
export { metricsAPI } from './metrics';
export * from './commercial';

View 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)}`))
};

View File

@@ -157,3 +157,89 @@ export const ACTIVITY_STATUS: Option[] = [
{ value: 'completed', label: 'Completada' },
{ 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' }
];

View File

@@ -4,6 +4,7 @@ import {
Users,
Shield,
Briefcase,
Ship,
} from '@lucide/svelte';
export type SystemContext = 'fixed_asset' | 'inventory';
@@ -44,11 +45,21 @@ export function getNavMain(): NavMainItem[] {
{ title: 'Clientes / Prospectos', url: '/dashboard/crm/cuentas' },
{ title: 'Proveedores', url: '/dashboard/crm/proveedores' },
{ 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: 'Oportunidades', url: '/dashboard/crm/oportunidades' },
{ title: 'Actividades', url: '/dashboard/crm/actividades' },
],
},
{
title: 'Operaciones',
url: '/dashboard/ops/embarques',
icon: Ship,
items: [
{ title: 'Embarques', url: '/dashboard/ops/embarques' },
],
},
{
title: 'Usuarios',
url: '/dashboard/users',

View 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>

View 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>

View File

@@ -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>

View 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>

View 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>

View File

@@ -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>

View 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>

View 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>

View File

@@ -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>