From 79135d9b5a16fb121e43213baacf88f3f0a41ac2 Mon Sep 17 00:00:00 2001 From: Aduanasoft Date: Tue, 14 Jul 2026 16:19:14 -0600 Subject: [PATCH] =?UTF-8?q?feat(crm):=20frontend=20de=20cat=C3=A1logos=20(?= =?UTF-8?q?Proveedores,=20Clientes=20enriquecido,=20detalle)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Clientes/Prospectos: formulario por secciones (generales, comercial, fiscal) - Proveedores: nueva página con clasificación múltiple y coberturas - Páginas de detalle cliente/proveedor con gestión de múltiples direcciones, contactos y documentos (componente RelatedManager reutilizable) - Clientes API tipados (suppliers, addresses, documents) + sidebar actualizado - svelte-check sin errores de tipo en los archivos del CRM Co-Authored-By: Claude Opus 4.8 (1M context) --- frontend/src/lib/api/crm/addresses.ts | 36 +++ frontend/src/lib/api/crm/documents.ts | 36 +++ frontend/src/lib/api/crm/index.ts | 3 + frontend/src/lib/api/crm/suppliers.ts | 39 +++ frontend/src/lib/api/crm/types.ts | 140 +++++++++- .../lib/components/crm/RelatedManager.svelte | 258 ++++++++++++++++++ frontend/src/lib/components/crm/format.ts | 123 ++++++++- .../src/lib/components/sidebar/modules.ts | 5 +- .../routes/dashboard/crm/cuentas/+page.svelte | 207 +++++++------- .../dashboard/crm/cuentas/[id]/+page.svelte | 71 +++++ .../dashboard/crm/proveedores/+page.svelte | 257 +++++++++++++++++ .../crm/proveedores/[id]/+page.svelte | 71 +++++ 12 files changed, 1112 insertions(+), 134 deletions(-) create mode 100644 frontend/src/lib/api/crm/addresses.ts create mode 100644 frontend/src/lib/api/crm/documents.ts create mode 100644 frontend/src/lib/api/crm/suppliers.ts create mode 100644 frontend/src/lib/components/crm/RelatedManager.svelte create mode 100644 frontend/src/routes/dashboard/crm/cuentas/[id]/+page.svelte create mode 100644 frontend/src/routes/dashboard/crm/proveedores/+page.svelte create mode 100644 frontend/src/routes/dashboard/crm/proveedores/[id]/+page.svelte diff --git a/frontend/src/lib/api/crm/addresses.ts b/frontend/src/lib/api/crm/addresses.ts new file mode 100644 index 0000000..c8b79c1 --- /dev/null +++ b/frontend/src/lib/api/crm/addresses.ts @@ -0,0 +1,36 @@ +/** + * Cliente API — Direcciones CRM (de un cliente o proveedor) + */ +import { api } from '$lib/api'; +import type { Address, AddressInput } from './types'; + +export const addressesAPI = { + async list( + companyId: number, + params?: { account_id?: number; supplier_id?: number } + ): Promise { + const qs = new URLSearchParams({ company_id: String(companyId) }); + if (params?.account_id) qs.set('account_id', String(params.account_id)); + if (params?.supplier_id) qs.set('supplier_id', String(params.supplier_id)); + const res = await api.get(`/v1/crm/addresses?${qs}`); + if (res.error) throw new Error(res.error); + return res.data!; + }, + + async create(data: AddressInput, companyId: number): Promise
{ + const res = await api.post
(`/v1/crm/addresses?company_id=${companyId}`, data); + if (res.error) throw new Error(res.error); + return res.data!; + }, + + async update(id: number, data: Partial, companyId: number): Promise
{ + const res = await api.patch
(`/v1/crm/addresses/${id}?company_id=${companyId}`, data); + if (res.error) throw new Error(res.error); + return res.data!; + }, + + async remove(id: number, companyId: number): Promise { + const res = await api.delete(`/v1/crm/addresses/${id}?company_id=${companyId}`); + if (res.error) throw new Error(res.error); + } +}; diff --git a/frontend/src/lib/api/crm/documents.ts b/frontend/src/lib/api/crm/documents.ts new file mode 100644 index 0000000..cd24eaf --- /dev/null +++ b/frontend/src/lib/api/crm/documents.ts @@ -0,0 +1,36 @@ +/** + * Cliente API — Documentos CRM (de un cliente o proveedor) + */ +import { api } from '$lib/api'; +import type { Document, DocumentInput } from './types'; + +export const documentsAPI = { + async list( + companyId: number, + params?: { account_id?: number; supplier_id?: number } + ): Promise { + const qs = new URLSearchParams({ company_id: String(companyId) }); + if (params?.account_id) qs.set('account_id', String(params.account_id)); + if (params?.supplier_id) qs.set('supplier_id', String(params.supplier_id)); + const res = await api.get(`/v1/crm/documents?${qs}`); + if (res.error) throw new Error(res.error); + return res.data!; + }, + + async create(data: DocumentInput, companyId: number): Promise { + const res = await api.post(`/v1/crm/documents?company_id=${companyId}`, data); + if (res.error) throw new Error(res.error); + return res.data!; + }, + + async update(id: number, data: Partial, companyId: number): Promise { + const res = await api.patch(`/v1/crm/documents/${id}?company_id=${companyId}`, data); + if (res.error) throw new Error(res.error); + return res.data!; + }, + + async remove(id: number, companyId: number): Promise { + const res = await api.delete(`/v1/crm/documents/${id}?company_id=${companyId}`); + if (res.error) throw new Error(res.error); + } +}; diff --git a/frontend/src/lib/api/crm/index.ts b/frontend/src/lib/api/crm/index.ts index 9ad63f5..7949951 100644 --- a/frontend/src/lib/api/crm/index.ts +++ b/frontend/src/lib/api/crm/index.ts @@ -3,7 +3,10 @@ */ export * from './types'; export { accountsAPI } from './accounts'; +export { suppliersAPI } from './suppliers'; export { contactsAPI } from './contacts'; +export { addressesAPI } from './addresses'; +export { documentsAPI } from './documents'; export { leadsAPI } from './leads'; export { pipelinesAPI, stagesAPI, type StageInput } from './pipelines'; export { opportunitiesAPI } from './opportunities'; diff --git a/frontend/src/lib/api/crm/suppliers.ts b/frontend/src/lib/api/crm/suppliers.ts new file mode 100644 index 0000000..9dba05c --- /dev/null +++ b/frontend/src/lib/api/crm/suppliers.ts @@ -0,0 +1,39 @@ +/** + * Cliente API — Proveedores CRM + */ +import { api } from '$lib/api'; +import type { Supplier, SupplierInput } from './types'; + +export const suppliersAPI = { + async list(companyId: number, params?: { search?: string; status?: string }): Promise { + const qs = new URLSearchParams({ company_id: String(companyId) }); + if (params?.search) qs.set('search', params.search); + if (params?.status) qs.set('status', params.status); + const res = await api.get(`/v1/crm/suppliers?${qs}`); + if (res.error) throw new Error(res.error); + return res.data!; + }, + + async get(id: number, companyId: number): Promise { + const res = await api.get(`/v1/crm/suppliers/${id}?company_id=${companyId}`); + if (res.error) throw new Error(res.error); + return res.data!; + }, + + async create(data: SupplierInput, companyId: number): Promise { + const res = await api.post(`/v1/crm/suppliers?company_id=${companyId}`, data); + if (res.error) throw new Error(res.error); + return res.data!; + }, + + async update(id: number, data: Partial, companyId: number): Promise { + const res = await api.patch(`/v1/crm/suppliers/${id}?company_id=${companyId}`, data); + if (res.error) throw new Error(res.error); + return res.data!; + }, + + async remove(id: number, companyId: number): Promise { + const res = await api.delete(`/v1/crm/suppliers/${id}?company_id=${companyId}`); + if (res.error) throw new Error(res.error); + } +}; diff --git a/frontend/src/lib/api/crm/types.ts b/frontend/src/lib/api/crm/types.ts index a1fe54f..f8082c4 100644 --- a/frontend/src/lib/api/crm/types.ts +++ b/frontend/src/lib/api/crm/types.ts @@ -2,51 +2,124 @@ * Tipos del módulo CRM — reflejan los DTOs del backend (api/v1/modules/crm). */ -export type AccountStatus = 'active' | 'inactive' | 'prospect'; +export type AccountStatus = 'active' | 'inactive'; +export type RecordType = 'cliente' | 'prospecto'; +export type PersonType = 'fisica' | 'moral'; export type LeadStatus = 'new' | 'contacted' | 'qualified' | 'unqualified' | 'converted'; export type OpportunityStatus = 'open' | 'won' | 'lost'; export type ActivityType = 'call' | 'meeting' | 'task' | 'email' | 'note'; export type ActivityStatus = 'pending' | 'completed' | 'canceled'; +// ---------- Clientes / Prospectos ---------- export interface Account { id: number; name: string; trade_name: string | null; rfc: string | null; - account_type: string | null; + curp: string | null; + record_type: RecordType; + person_type: string | null; industry: string | null; + account_type: string | null; + status: AccountStatus; + commercial_classification: string | null; + preferred_contact_method: string | null; + language: string | null; email: string | null; phone: string | null; website: string | null; + tax_regime: string | null; + cfdi_use: string | null; + payment_method: string | null; + payment_form: string | null; + currency: string | null; + credit_limit: number | null; + credit_days: number | null; + commercial_terms: string | null; + patente_aduanal: string | null; address: string | null; city: string | null; state: string | null; country: string | null; - patente_aduanal: string | null; - status: AccountStatus; - owner_user_id: string | null; notes: string | null; + internal_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 AccountInput = Partial> & { +export type AccountInput = Partial> & { name: string; }; +// ---------- Proveedores ---------- +export interface Supplier { + id: number; + name: string; + trade_name: string | null; + rfc: string | null; + curp: string | null; + person_type: string | null; + status: AccountStatus; + classifications: string[]; + services_offered: string | null; + coverage: string | null; + countries: string[]; + ports: string[]; + airports: string[]; + customs: string[]; + business_hours: string | null; + quote_currency: string | null; + avg_response_time: string | null; + commercial_notes: string | null; + email: string | null; + phone: string | null; + website: string | null; + tax_regime: string | null; + payment_method: string | null; + payment_form: string | null; + credit_limit: number | null; + credit_days: number | null; + commercial_terms: string | null; + notes: string | null; + internal_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 SupplierInput = Partial> & { + name: string; +}; + +// ---------- Contactos ---------- export interface Contact { id: number; account_id: number | null; + supplier_id: number | null; first_name: string; last_name: string | null; - email: string | null; - phone: string | null; - mobile: string | null; job_title: string | null; department: string | null; + area: string | null; + email: string | null; + phone: string | null; + extension: string | null; + mobile: string | null; + whatsapp: string | null; is_primary: boolean; + receives_quotes: boolean; + receives_invoices: boolean; + receives_commercial_info: boolean; + status: string; owner_user_id: string | null; notes: string | null; tenant_id: number; @@ -59,6 +132,54 @@ export type ContactInput = Partial>; + +// ---------- Documentos ---------- +export interface Document { + id: number; + account_id: number | null; + supplier_id: number | null; + doc_type: string; + name: string; + file_key: string | null; + file_url: string | null; + content_type: string | null; + size_bytes: number | null; + uploaded_by: string | null; + tenant_id: number; + company_id: number; + created_at: string; + updated_at: string; +} + +export type DocumentInput = Partial> & { + doc_type: string; + name: string; +}; + +// ---------- Prospectos (leads / funnel) ---------- export interface Lead { id: number; name: string; @@ -99,6 +220,7 @@ export interface LeadConvertResult { opportunity_id: number | null; } +// ---------- Embudo / Oportunidades ---------- export interface Pipeline { id: number; name: string; diff --git a/frontend/src/lib/components/crm/RelatedManager.svelte b/frontend/src/lib/components/crm/RelatedManager.svelte new file mode 100644 index 0000000..a548a2d --- /dev/null +++ b/frontend/src/lib/components/crm/RelatedManager.svelte @@ -0,0 +1,258 @@ + + +
+ + + + Direcciones + + + + {#if addresses.length === 0} +

Sin direcciones.

+ {:else} + + TipoDomicilioCPCiudad + + {#each addresses as a (a.id)} + + {labelOf(ADDRESS_TYPES, a.address_type)}{#if a.is_primary}(principal){/if} + {[a.street, a.ext_number, a.neighborhood].filter(Boolean).join(' ') || '—'} + {a.postal_code ?? '—'} + {[a.city, a.state].filter(Boolean).join(', ') || '—'} + + + {/each} + + + {/if} +
+
+ + + + + Contactos + + + + {#if contacts.length === 0} +

Sin contactos.

+ {:else} + + NombrePuesto / ÁreaEmailTeléfono + + {#each contacts as c (c.id)} + + {c.first_name} {c.last_name ?? ''}{#if c.is_primary}(principal){/if} + {[c.job_title, labelOf(CONTACT_AREAS, c.area) !== '—' ? labelOf(CONTACT_AREAS, c.area) : null].filter(Boolean).join(' · ') || '—'} + {c.email ?? '—'} + {c.phone ?? c.mobile ?? '—'} + + + {/each} + + + {/if} +
+
+ + + + + Documentos + + + + {#if documents.length === 0} +

Sin documentos.

+ {:else} + + TipoNombreArchivo + + {#each documents as d (d.id)} + + {labelOf(DOC_TYPES, d.doc_type)} + {d.name} + {#if d.file_url}Ver{:else}—{/if} + + + {/each} + + + {/if} +
+
+
+ +{#if activeModal} + +{/if} diff --git a/frontend/src/lib/components/crm/format.ts b/frontend/src/lib/components/crm/format.ts index c820344..383a0d3 100644 --- a/frontend/src/lib/components/crm/format.ts +++ b/frontend/src/lib/components/crm/format.ts @@ -1,7 +1,12 @@ /** - * Utilidades de formato y etiquetas legibles para el CRM. + * Utilidades de formato y catálogos de etiquetas para el CRM. */ +export interface Option { + value: string; + label: string; +} + export function formatMoney(value: number | null | undefined, currency = 'MXN'): string { if (value === null || value === undefined) return '—'; return new Intl.NumberFormat('es-MX', { style: 'currency', currency }).format(Number(value)); @@ -14,7 +19,34 @@ export function formatDate(value: string | null | undefined): string { return d.toLocaleDateString('es-MX', { year: 'numeric', month: 'short', day: 'numeric' }); } -export const ACCOUNT_TYPES: { value: string; label: string }[] = [ +export function labelOf(list: Option[], value: string | null | undefined): string { + if (!value) return '—'; + return list.find((x) => x.value === value)?.label ?? value; +} + +// ----- Clientes / Prospectos ----- +export const RECORD_TYPES: Option[] = [ + { value: 'cliente', label: 'Cliente' }, + { value: 'prospecto', label: 'Prospecto' } +]; + +export const PERSON_TYPES: Option[] = [ + { value: 'fisica', label: 'Física' }, + { value: 'moral', label: 'Moral' } +]; + +export const ACCOUNT_STATUS: Option[] = [ + { value: 'active', label: 'Activo' }, + { value: 'inactive', label: 'Inactivo' } +]; + +export const COMMERCIAL_CLASSIFICATION: Option[] = [ + { value: 'importador', label: 'Importador' }, + { value: 'exportador', label: 'Exportador' }, + { value: 'ambos', label: 'Importador / Exportador' } +]; + +export const ACCOUNT_TYPES: Option[] = [ { value: 'importador', label: 'Importador' }, { value: 'exportador', label: 'Exportador' }, { value: 'immex', label: 'IMMEX / Maquila' }, @@ -23,13 +55,78 @@ export const ACCOUNT_TYPES: { value: string; label: string }[] = [ { value: 'otro', label: 'Otro' } ]; -export const ACCOUNT_STATUS: { value: string; label: string }[] = [ - { value: 'active', label: 'Activa' }, - { value: 'prospect', label: 'Prospecto' }, - { value: 'inactive', label: 'Inactiva' } +export const CONTACT_METHODS: Option[] = [ + { value: 'llamada', label: 'Llamada telefónica' }, + { value: 'correo', label: 'Correo electrónico' }, + { value: 'videollamada', label: 'Videoconferencia' }, + { value: 'whatsapp', label: 'WhatsApp' }, + { value: 'otro', label: 'Otro' } ]; -export const LEAD_SOURCES: { value: string; label: string }[] = [ +// ----- Proveedores ----- +export const SUPPLIER_CLASSIFICATIONS: Option[] = [ + { value: 'naviera', label: 'Naviera' }, + { value: 'aerolinea', label: 'Aerolínea' }, + { value: 'transportista_terrestre', label: 'Transportista terrestre' }, + { value: 'ferrocarril', label: 'Ferrocarril' }, + { value: 'agente_aduanal', label: 'Agente aduanal' }, + { value: 'agente_carga', label: 'Agente de carga' }, + { value: 'agente_corresponsal', label: 'Agente corresponsal' }, + { value: 'almacen', label: 'Almacén' }, + { value: 'aseguradora', label: 'Aseguradora' }, + { value: 'paqueteria', label: 'Paquetería' }, + { value: 'otro', label: 'Otro' } +]; + +export const COVERAGE: Option[] = [ + { value: 'nacional', label: 'Nacional' }, + { value: 'internacional', label: 'Internacional' }, + { value: 'ambos', label: 'Nacional e internacional' } +]; + +// ----- Direcciones ----- +export const ADDRESS_TYPES: Option[] = [ + { value: 'fiscal', label: 'Fiscal' }, + { value: 'oficina', label: 'Oficina' }, + { value: 'sucursal', label: 'Sucursal' }, + { value: 'bodega', label: 'Bodega' }, + { value: 'patio', label: 'Patio' }, + { value: 'terminal', label: 'Terminal' }, + { value: 'almacen', label: 'Almacén' } +]; + +// ----- Documentos ----- +export const DOC_TYPES: Option[] = [ + { value: 'constancia_fiscal', label: 'Constancia de Situación Fiscal' }, + { value: 'acta_constitutiva', label: 'Acta Constitutiva' }, + { value: 'identificacion', label: 'Identificación Oficial' }, + { value: 'comprobante_domicilio', label: 'Comprobante de Domicilio' }, + { value: 'contrato', label: 'Contrato' }, + { value: 'presentacion', label: 'Presentación Comercial' }, + { value: 'certificacion', label: 'Certificación' }, + { value: 'licencia', label: 'Licencia' }, + { value: 'convenio', label: 'Convenio' }, + { value: 'tarifario', label: 'Tarifario' }, + { value: 'otro', label: 'Otro' } +]; + +// ----- Contactos ----- +export const CONTACT_AREAS: Option[] = [ + { value: 'ventas', label: 'Ventas' }, + { value: 'operaciones', label: 'Operaciones' }, + { value: 'facturacion', label: 'Facturación' }, + { value: 'cobranza', label: 'Cobranza' }, + { value: 'servicio_cliente', label: 'Servicio al Cliente' }, + { value: 'otro', label: 'Otro' } +]; + +export const CONTACT_STATUS: Option[] = [ + { value: 'active', label: 'Activo' }, + { value: 'inactive', label: 'Inactivo' } +]; + +// ----- Prospectos (leads / funnel) ----- +export const LEAD_SOURCES: Option[] = [ { value: 'web', label: 'Web' }, { value: 'referido', label: 'Referido' }, { value: 'evento', label: 'Evento' }, @@ -38,7 +135,7 @@ export const LEAD_SOURCES: { value: string; label: string }[] = [ { value: 'otro', label: 'Otro' } ]; -export const LEAD_STATUS: { value: string; label: string }[] = [ +export const LEAD_STATUS: Option[] = [ { value: 'new', label: 'Nuevo' }, { value: 'contacted', label: 'Contactado' }, { value: 'qualified', label: 'Calificado' }, @@ -46,7 +143,8 @@ export const LEAD_STATUS: { value: string; label: string }[] = [ { value: 'converted', label: 'Convertido' } ]; -export const ACTIVITY_TYPES: { value: string; label: string }[] = [ +// ----- Actividades ----- +export const ACTIVITY_TYPES: Option[] = [ { value: 'call', label: 'Llamada' }, { value: 'meeting', label: 'Reunión' }, { value: 'task', label: 'Tarea' }, @@ -54,13 +152,8 @@ export const ACTIVITY_TYPES: { value: string; label: string }[] = [ { value: 'note', label: 'Nota' } ]; -export const ACTIVITY_STATUS: { value: string; label: string }[] = [ +export const ACTIVITY_STATUS: Option[] = [ { value: 'pending', label: 'Pendiente' }, { value: 'completed', label: 'Completada' }, { value: 'canceled', label: 'Cancelada' } ]; - -export function labelOf(list: { value: string; label: string }[], value: string | null): string { - if (!value) return '—'; - return list.find((x) => x.value === value)?.label ?? value; -} diff --git a/frontend/src/lib/components/sidebar/modules.ts b/frontend/src/lib/components/sidebar/modules.ts index af8dabe..d1f0c72 100644 --- a/frontend/src/lib/components/sidebar/modules.ts +++ b/frontend/src/lib/components/sidebar/modules.ts @@ -41,9 +41,10 @@ export function getNavMain(): NavMainItem[] { icon: Briefcase, items: [ { title: 'Panel', url: '/dashboard/crm' }, - { title: 'Cuentas', url: '/dashboard/crm/cuentas' }, + { title: 'Clientes / Prospectos', url: '/dashboard/crm/cuentas' }, + { title: 'Proveedores', url: '/dashboard/crm/proveedores' }, { title: 'Contactos', url: '/dashboard/crm/contactos' }, - { title: 'Prospectos', url: '/dashboard/crm/prospectos' }, + { title: 'Prospectos (embudo)', url: '/dashboard/crm/prospectos' }, { title: 'Oportunidades', url: '/dashboard/crm/oportunidades' }, { title: 'Actividades', url: '/dashboard/crm/actividades' }, ], diff --git a/frontend/src/routes/dashboard/crm/cuentas/+page.svelte b/frontend/src/routes/dashboard/crm/cuentas/+page.svelte index 1aa1e8a..24a9b89 100644 --- a/frontend/src/routes/dashboard/crm/cuentas/+page.svelte +++ b/frontend/src/routes/dashboard/crm/cuentas/+page.svelte @@ -1,31 +1,36 @@
@@ -106,41 +108,43 @@

- Cuentas + Clientes / Prospectos

-

Empresas cliente y prospectos.

+

Catálogo de clientes y prospectos.

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

Cargando…

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

Sin cuentas registradas.

+

Sin registros.

{:else}
- Nombre - RFC + Razón social Tipo - Estado - Teléfono + RFC + Clasificación + Estatus Acciones @@ -148,18 +152,21 @@ {#each filtered as a (a.id)} - {a.name} + {a.name} {#if a.trade_name}{a.trade_name}{/if} - {a.rfc ?? '—'} - {labelOf(ACCOUNT_TYPES, a.account_type)} - - {labelOf(ACCOUNT_STATUS, a.status)} + + {labelOf(RECORD_TYPES, a.record_type)} - {a.phone ?? '—'} + {a.rfc ?? '—'} + {labelOf(COMMERCIAL_CLASSIFICATION, a.commercial_classification)} + {labelOf(ACCOUNT_STATUS, a.status)} + @@ -178,69 +185,53 @@
{#if modalOpen} -