From e724aeae50103d3b6918da44d26728fe08b7383e Mon Sep 17 00:00:00 2001 From: Aduanasoft Date: Wed, 15 Jul 2026 07:24:15 -0600 Subject: [PATCH] =?UTF-8?q?feat(fin,ops):=20frontend=20Facturas/Cobranza,?= =?UTF-8?q?=20bit=C3=A1cora=20de=20embarque=20y=20uploader=20de=20document?= =?UTF-8?q?os?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Facturas: lista, alta y detalle (Conceptos / Pagos-cobranza / Datos) con totales+IVA+saldo y acciones Emitir/Enviar/Cancelar - Embarque: botón "Generar factura", pestaña "Bitácora" (hitos por defecto import/export, completar/agregar) y subida real de archivos a MinIO en documentos - Clientes/Proveedores: subida de archivos en documentos (RelatedManager) - clientes API fin + ops(eventos) + uploads; sidebar grupo Facturación - svelte-check: 0 errores de tipo en archivos nuevos Co-Authored-By: Claude Opus 4.8 (1M context) --- frontend/src/lib/api/fin/index.ts | 107 ++++++++ frontend/src/lib/api/ops/index.ts | 33 ++- frontend/src/lib/api/uploads.ts | 33 +++ .../lib/components/crm/RelatedManager.svelte | 38 ++- frontend/src/lib/components/crm/format.ts | 17 ++ .../src/lib/components/sidebar/modules.ts | 9 + .../dashboard/fin/facturas/+page.svelte | 121 +++++++++ .../dashboard/fin/facturas/[id]/+page.svelte | 230 ++++++++++++++++++ .../dashboard/fin/facturas/nuevo/+page.svelte | 60 +++++ .../dashboard/ops/embarques/[id]/+page.svelte | 163 +++++++++++-- 10 files changed, 782 insertions(+), 29 deletions(-) create mode 100644 frontend/src/lib/api/fin/index.ts create mode 100644 frontend/src/lib/api/uploads.ts create mode 100644 frontend/src/routes/dashboard/fin/facturas/+page.svelte create mode 100644 frontend/src/routes/dashboard/fin/facturas/[id]/+page.svelte create mode 100644 frontend/src/routes/dashboard/fin/facturas/nuevo/+page.svelte diff --git a/frontend/src/lib/api/fin/index.ts b/frontend/src/lib/api/fin/index.ts new file mode 100644 index 0000000..3d4a947 --- /dev/null +++ b/frontend/src/lib/api/fin/index.ts @@ -0,0 +1,107 @@ +/** + * Cliente API — Facturación y Cobranza. + */ +import { api } from '$lib/api'; + +export type InvoiceStatus = 'borrador' | 'emitida' | 'enviada' | 'pagada' | 'cancelada'; + +export interface Invoice { + id: number; + reference: string | null; + shipment_id: number | null; + quote_id: number | null; + account_id: number | null; + currency: string; + status: InvoiceStatus; + issue_date: string | null; + due_date: string | null; + subtotal: number; + tax_rate: number; + tax_amount: number; + total: number; + paid_amount: number; + balance: number; + bank_info: string | null; + notes: string | null; + sent_at: string | null; + paid_at: 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 InvoiceInput = Partial>; + +export interface InvoiceItem { + id: number; + invoice_id: number; + concept: string; + description: string | null; + quantity: number; + unit_amount: number; + line_total: number; + tenant_id: number; + company_id: number; +} +export type InvoiceItemInput = Partial> & { + invoice_id: number; + concept: string; +}; + +export interface Payment { + id: number; + invoice_id: number; + amount: number; + payment_date: string | null; + method: string | null; + reference: string | null; + notes: string | null; + tenant_id: number; + company_id: number; + created_at: string; +} +export type PaymentInput = Partial> & { + invoice_id: number; + amount: number; +}; + +function qp(companyId: number, extra?: Record) { + 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(p: Promise<{ data?: T; error?: string }>): Promise { + const res = await p; + if (res.error) throw new Error(res.error); + return res.data as T; +} + +export const invoicesAPI = { + list: (companyId: number, params?: { search?: string; status?: string; account_id?: number }) => + unwrap(api.get(`/v1/fin/invoices?${qp(companyId, params)}`)), + get: (id: number, companyId: number) => unwrap(api.get(`/v1/fin/invoices/${id}?${qp(companyId)}`)), + create: (data: InvoiceInput, companyId: number) => unwrap(api.post(`/v1/fin/invoices?${qp(companyId)}`, data)), + fromShipment: (shipmentId: number, companyId: number) => + unwrap(api.post(`/v1/fin/invoices/from-shipment?${qp(companyId, { shipment_id: shipmentId })}`, {})), + update: (id: number, data: Partial, companyId: number) => unwrap(api.patch(`/v1/fin/invoices/${id}?${qp(companyId)}`, data)), + emit: (id: number, companyId: number) => unwrap(api.patch(`/v1/fin/invoices/${id}/emit?${qp(companyId)}`, {})), + send: (id: number, companyId: number) => unwrap(api.patch(`/v1/fin/invoices/${id}/send?${qp(companyId)}`, {})), + cancel: (id: number, companyId: number) => unwrap(api.patch(`/v1/fin/invoices/${id}/cancel?${qp(companyId)}`, {})), + remove: (id: number, companyId: number) => unwrap(api.delete(`/v1/fin/invoices/${id}?${qp(companyId)}`)), + items: (id: number, companyId: number) => unwrap(api.get(`/v1/fin/invoices/${id}/items?${qp(companyId)}`)), + payments: (id: number, companyId: number) => unwrap(api.get(`/v1/fin/invoices/${id}/payments?${qp(companyId)}`)) +}; + +export const invoiceItemsAPI = { + create: (data: InvoiceItemInput, companyId: number) => unwrap(api.post(`/v1/fin/invoice-items?${qp(companyId)}`, data)), + update: (id: number, data: Partial, companyId: number) => unwrap(api.patch(`/v1/fin/invoice-items/${id}?${qp(companyId)}`, data)), + remove: (id: number, companyId: number) => unwrap(api.delete(`/v1/fin/invoice-items/${id}?${qp(companyId)}`)) +}; + +export const paymentsAPI = { + create: (data: PaymentInput, companyId: number) => unwrap(api.post(`/v1/fin/payments?${qp(companyId)}`, data)), + remove: (id: number, companyId: number) => unwrap(api.delete(`/v1/fin/payments/${id}?${qp(companyId)}`)) +}; diff --git a/frontend/src/lib/api/ops/index.ts b/frontend/src/lib/api/ops/index.ts index 9ecabb2..f60648c 100644 --- a/frontend/src/lib/api/ops/index.ts +++ b/frontend/src/lib/api/ops/index.ts @@ -39,6 +39,26 @@ export interface Shipment { } export type ShipmentInput = Partial>; +export interface ShipmentEvent { + id: number; + shipment_id: number; + event_type: string | null; + title: string; + status: string; + position: number; + planned_date: string | null; + actual_date: string | null; + notes: string | null; + tenant_id: number; + company_id: number; + created_at: string; + updated_at: string; +} +export type ShipmentEventInput = Partial> & { + shipment_id: number; + title: string; +}; + export interface ShipmentDocument { id: number; shipment_id: number; @@ -80,7 +100,18 @@ export const shipmentsAPI = { update: (id: number, data: Partial, companyId: number) => unwrap(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(api.get(`/v1/ops/shipments/${shipmentId}/documents?${qp(companyId)}`)) + unwrap(api.get(`/v1/ops/shipments/${shipmentId}/documents?${qp(companyId)}`)), + events: (shipmentId: number, companyId: number) => + unwrap(api.get(`/v1/ops/shipments/${shipmentId}/events?${qp(companyId)}`)), + seedEvents: (shipmentId: number, companyId: number) => + unwrap(api.post(`/v1/ops/shipments/${shipmentId}/events/seed?${qp(companyId)}`, {})) +}; + +export const shipmentEventsAPI = { + create: (data: ShipmentEventInput, companyId: number) => unwrap(api.post(`/v1/ops/shipment-events?${qp(companyId)}`, data)), + update: (id: number, data: Partial, companyId: number) => unwrap(api.patch(`/v1/ops/shipment-events/${id}?${qp(companyId)}`, data)), + complete: (id: number, companyId: number) => unwrap(api.patch(`/v1/ops/shipment-events/${id}/complete?${qp(companyId)}`, {})), + remove: (id: number, companyId: number) => unwrap(api.delete(`/v1/ops/shipment-events/${id}?${qp(companyId)}`)) }; export const shipmentDocumentsAPI = { diff --git a/frontend/src/lib/api/uploads.ts b/frontend/src/lib/api/uploads.ts new file mode 100644 index 0000000..31dcf25 --- /dev/null +++ b/frontend/src/lib/api/uploads.ts @@ -0,0 +1,33 @@ +/** + * Cliente API — subida de archivos a MinIO/S3 (documentos del CRM y Operaciones). + */ +import { api } from '$lib/api'; + +export interface UploadResult { + file_key: string; + file_url: string; + name: string; + content_type: string | null; + size_bytes: number; +} + +/** Sube un archivo (multipart) y devuelve su file_key permanente + URL firmada. */ +export async function uploadFile(file: File, companyId: number): Promise { + const fd = new FormData(); + fd.append('file', file); + const res = await api.request(`/v1/crm/uploads?company_id=${companyId}`, { + method: 'POST', + body: fd + }); + if (res.error) throw new Error(res.error); + return res.data!; +} + +/** Obtiene una URL firmada fresca para abrir un archivo por su file_key. */ +export async function uploadUrl(fileKey: string, companyId: number): Promise { + const res = await api.get<{ url: string }>( + `/v1/crm/uploads/url?key=${encodeURIComponent(fileKey)}&company_id=${companyId}` + ); + if (res.error) throw new Error(res.error); + return res.data!.url; +} diff --git a/frontend/src/lib/components/crm/RelatedManager.svelte b/frontend/src/lib/components/crm/RelatedManager.svelte index 2a10584..ec3f880 100644 --- a/frontend/src/lib/components/crm/RelatedManager.svelte +++ b/frontend/src/lib/components/crm/RelatedManager.svelte @@ -9,6 +9,7 @@ type Address, type Contact, type Document, type AddressInput, type ContactInput, type DocumentInput } from '$lib/api/crm'; import { ADDRESS_TYPES, DOC_TYPES, CONTACT_AREAS, labelOf } from '$lib/components/crm/format'; + import { uploadFile, uploadUrl } from '$lib/api/uploads'; import { toast } from 'svelte-sonner'; // Dueño de los registros relacionados y qué sección mostrar @@ -33,6 +34,34 @@ let addressForm = $state({ address_type: 'fiscal', country: 'MX', is_primary: false }); let contactForm = $state({ first_name: '' }); let documentForm = $state({ doc_type: 'constancia_fiscal', name: '' }); + let uploading = $state(false); + + async function onFilePicked(e: Event) { + const input = e.target as HTMLInputElement; + const file = input.files?.[0]; + if (!file || !companyId) return; + uploading = true; + try { + const up = await uploadFile(file, companyId); + documentForm = { ...documentForm, file_key: up.file_key, file_url: up.file_url, name: documentForm.name || up.name }; + toast.success('Archivo subido'); + } catch (err) { + toast.error(err instanceof Error ? err.message : 'No se pudo subir el archivo'); + } finally { + uploading = false; + } + } + + async function openDoc(d: Document) { + if (!companyId) return; + try { + const url = d.file_key ? await uploadUrl(d.file_key, companyId) : d.file_url; + if (url) window.open(url, '_blank', 'noopener'); + else toast.error('El documento no tiene archivo'); + } catch (e) { + toast.error(e instanceof Error ? e.message : 'No se pudo abrir el archivo'); + } + } const companyId = $derived(companyStore.activeCompany?.id ?? null); const ownerParam = $derived(ownerType === 'account' ? { account_id: ownerId } : { supplier_id: ownerId }); @@ -205,7 +234,7 @@ {labelOf(DOC_TYPES, d.doc_type)} {d.name} - {#if d.file_url}Ver{:else}—{/if} + {#if d.file_key || d.file_url}{:else}—{/if} {/each} @@ -261,8 +290,11 @@
- -

La subida de archivos a MinIO se conectará en una siguiente iteración; por ahora se registra la referencia (URL).

+ +
{/if} diff --git a/frontend/src/lib/components/crm/format.ts b/frontend/src/lib/components/crm/format.ts index 6b118d5..97969d3 100644 --- a/frontend/src/lib/components/crm/format.ts +++ b/frontend/src/lib/components/crm/format.ts @@ -231,6 +231,23 @@ export const DOC_KINDS: Option[] = [ { value: 'otro', label: 'Otro' } ]; +// ----- Facturación ----- +export const INVOICE_STATUS: Option[] = [ + { value: 'borrador', label: 'Borrador' }, + { value: 'emitida', label: 'Emitida' }, + { value: 'enviada', label: 'Enviada' }, + { value: 'pagada', label: 'Pagada' }, + { value: 'cancelada', label: 'Cancelada' } +]; + +export const PAYMENT_METHODS: Option[] = [ + { value: 'transferencia', label: 'Transferencia' }, + { value: 'efectivo', label: 'Efectivo' }, + { value: 'cheque', label: 'Cheque' }, + { value: 'tarjeta', label: 'Tarjeta' }, + { 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)' }, diff --git a/frontend/src/lib/components/sidebar/modules.ts b/frontend/src/lib/components/sidebar/modules.ts index 8658722..dbd39e0 100644 --- a/frontend/src/lib/components/sidebar/modules.ts +++ b/frontend/src/lib/components/sidebar/modules.ts @@ -5,6 +5,7 @@ import { Shield, Briefcase, Ship, + Receipt, } from '@lucide/svelte'; export type SystemContext = 'fixed_asset' | 'inventory'; @@ -60,6 +61,14 @@ export function getNavMain(): NavMainItem[] { { title: 'Embarques', url: '/dashboard/ops/embarques' }, ], }, + { + title: 'Facturación', + url: '/dashboard/fin/facturas', + icon: Receipt, + items: [ + { title: 'Facturas y cobranza', url: '/dashboard/fin/facturas' }, + ], + }, { title: 'Usuarios', url: '/dashboard/users', diff --git a/frontend/src/routes/dashboard/fin/facturas/+page.svelte b/frontend/src/routes/dashboard/fin/facturas/+page.svelte new file mode 100644 index 0000000..55daf9e --- /dev/null +++ b/frontend/src/routes/dashboard/fin/facturas/+page.svelte @@ -0,0 +1,121 @@ + + +
+
+
+

Facturas y cobranza

+

Emisión de facturas y registro de pagos.

+
+ +
+ + + +
+
+ + +
+ +
+
+ + {#if loading} +

Cargando…

+ {:else if filtered.length === 0} +

Sin facturas.

+ {:else} +
+ + + + Folio + Estatus + Total + Saldo + Acciones + + + + {#each filtered as i (i.id)} + + {i.reference ?? `#${i.id}`} + {labelOf(INVOICE_STATUS, i.status)} + {formatMoney(i.total, i.currency)} + {formatMoney(i.balance, i.currency)} + + + + + + {/each} + + +
+ {/if} +
+
+
diff --git a/frontend/src/routes/dashboard/fin/facturas/[id]/+page.svelte b/frontend/src/routes/dashboard/fin/facturas/[id]/+page.svelte new file mode 100644 index 0000000..87bebb7 --- /dev/null +++ b/frontend/src/routes/dashboard/fin/facturas/[id]/+page.svelte @@ -0,0 +1,230 @@ + + +
+ + + {#if loading && !invoice} +

Cargando…

+ {:else if invoice} +
+
+

{invoice.reference ?? `Factura #${invoice.id}`}

+

{labelOf(INVOICE_STATUS, invoice.status)}

+
+
+ {#if invoice.status === 'borrador'}{/if} + {#if invoice.status === 'emitida'}{/if} + {#if invoice.status !== 'cancelada' && invoice.status !== 'pagada'}{/if} +
+
+ +
+ Subtotal{formatMoney(invoice.subtotal, invoice.currency)} + Impuesto ({invoice.tax_rate}%){formatMoney(invoice.tax_amount, invoice.currency)} + Total{formatMoney(invoice.total, invoice.currency)} + Saldo{formatMoney(invoice.balance, invoice.currency)} +
+ + + +
+ {#each [{ id: 'conceptos', label: 'Conceptos' }, { id: 'pagos', label: 'Pagos / cobranza' }, { id: 'datos', label: 'Datos' }] as t (t.id)} + + {/each} +
+ + {#if tab === 'conceptos'} +
+ {#if addingItem} +
+ + + + +
+
+ {/if} + {#if items.length === 0} +

Sin conceptos.

+ {:else} + + ConceptoCant.UnitarioImporte + + {#each items as it (it.id)} + + {labelOf(QUOTE_CONCEPTS, it.concept)}{#if it.description}{it.description}{/if} + {it.quantity} + {formatMoney(it.unit_amount, invoice.currency)} + {formatMoney(it.line_total, invoice.currency)} + + + {/each} + + + {/if} + {:else if tab === 'pagos'} +
+ {#if addingPay} +
+ + + + +
+
+ {/if} + {#if payments.length === 0} +

Sin pagos registrados.

+ {:else} + + FechaMétodoReferenciaMonto + + {#each payments as p (p.id)} + + {p.payment_date ?? '—'} + {labelOf(PAYMENT_METHODS, p.method)} + {p.reference ?? '—'} + {formatMoney(p.amount, invoice.currency)} + + + {/each} + + + {/if} + {:else} +
+ + + + + + + + +
+
+ {/if} +
+
+ {/if} +
diff --git a/frontend/src/routes/dashboard/fin/facturas/nuevo/+page.svelte b/frontend/src/routes/dashboard/fin/facturas/nuevo/+page.svelte new file mode 100644 index 0000000..b4c74ae --- /dev/null +++ b/frontend/src/routes/dashboard/fin/facturas/nuevo/+page.svelte @@ -0,0 +1,60 @@ + + +
+ +

Nueva factura

+

Tip: también puedes generar la factura automáticamente desde un embarque (botón en el embarque).

+ + + +
+ + + + + + +
+
+ + +
+
+
+
diff --git a/frontend/src/routes/dashboard/ops/embarques/[id]/+page.svelte b/frontend/src/routes/dashboard/ops/embarques/[id]/+page.svelte index f900ff6..745ac07 100644 --- a/frontend/src/routes/dashboard/ops/embarques/[id]/+page.svelte +++ b/frontend/src/routes/dashboard/ops/embarques/[id]/+page.svelte @@ -1,15 +1,18 @@ @@ -100,15 +178,18 @@ {#if loading && !shipment}

Cargando…

{:else if shipment} -
-

{shipment.reference ?? `Embarque #${shipment.id}`}

-

{labelOf(SHIPMENT_STATUS, shipment.status)}{#if shipment.booking_number} · Booking {shipment.booking_number}{/if}

+
+
+

{shipment.reference ?? `Embarque #${shipment.id}`}

+

{labelOf(SHIPMENT_STATUS, shipment.status)}{#if shipment.booking_number} · Booking {shipment.booking_number}{/if}

+
+
- {#each [{ id: 'datos', label: 'Datos del embarque' }, { id: 'documentos', label: 'Documentos' }] as t (t.id)} + {#each [{ id: 'datos', label: 'Datos del embarque' }, { id: 'documentos', label: 'Documentos' }, { id: 'bitacora', label: 'Bitácora' }] as t (t.id)} {/each}
@@ -135,16 +216,19 @@
- {:else} -
- {#if adding} + {:else if tab === 'documentos'} +
+ {#if addingDoc}
- + - -
+ +
{/if} {#if docs.length === 0} @@ -159,13 +243,42 @@ {labelOf(SHIPMENT_DOC_TYPES, d.doc_type)} {d.number ?? '—'} {formatDate(d.issue_date)} - {#if d.file_url}Ver{:else}—{/if} + {#if d.file_key || d.file_url}{:else}—{/if} {/each} {/if} + {:else} +
+ {#if events.length === 0}{/if} + +
+ {#if addingEvent} +
+ + +
+
+ {/if} + {#if events.length === 0} +

Sin hitos. Usa "Generar hitos" para crear la secuencia según el tipo de operación.

+ {:else} +
    + {#each events as ev (ev.id)} +
  1. + {ev.status === 'completado' ? '✓' : ev.position + 1} +
    +

    {ev.title}

    + {#if ev.actual_date}

    Completado: {formatDate(ev.actual_date)}

    {/if} +
    + {#if ev.status !== 'completado'}{/if} + +
  2. + {/each} +
+ {/if} {/if}