Skip to content

Módulo Web Cartera

Gestionar leads/clientes, filtros y detalle por cliente.

  • Cartera debe preferir siempre el endpoint más específico que ya ofrezca BoostAPI para la pantalla actual.
  • En un CRM grande, web no debe cargar catálogos o listas completas “por si acaso”; primero debe filtrar por group, lead, record_type o custom_field_group_id cuando el backend ya soporte ese recorte.
  • La UI solo debe pedir datos secundarios cuando realmente los vaya a renderizar.
  • Si un contrato backend nuevo elimina un fallback más pesado, el monorepo debe adoptarlo y documentarlo.
  • /cartera
  • /cartera/[id]
  • CarteraTable
  • FilterDrawer
  • ClientDetailShell y tabs derivadas.
  • apps/web/app/services/cartera.service.ts resuelve la lista mediante getServerCrmServices().
  • @repo/crm-services consume la paginación oficial de BoostAPI con el boostToken guardado en sesión web.
  • Regla actual de transporte:
    • Todos usa GET /groups?page=n
    • Clientes usa GET /groups?page=n
    • Leads usa GET /leads?page=n&record_type=lead
    • importante: por ahora Clientes no usa GET /leads?record_type=client; visualmente es otra vista del listado por grupos
    • cada respuesta trae data + pagination
    • web no vuelve a inventar páginas ni recalcula tamaños por su cuenta
  • Regla de UX/prefetch:
    • web pinta primero la página pedida
    • apenas esa página ya quedó renderizada, precarga silenciosamente la siguiente
    • cuando el usuario navega a la página siguiente, idealmente ya está cacheada en cliente
    • esa misma regla debe repetirse en cualquier otra vista paginada del CRM
  • Columnas conectadas hoy a backend: grupo, cliente/lead, creacion y vendedor.
  • vendedor se construye con GET /employees, GET /api/lead-assignments y GET /api/group-assignments.
  • La acción de asignar vendedor desde la tabla usa POST /api/group-assignments a través del route handler web app/api/cartera/seller-assignments/route.ts.
  • Columnas que siguen en mock temporal: contacto, ultimaCompra, frecuencia, agenda e interaccion.
  • La entrada a /cartera/[id] ya se alinea al contrato backend de detalle base de groups, no al detalle directo de leads.
  • Pendiente de futuro para la tabla:
    • BoostAPI todavía no expone un payload agregado único para mezclar groups + leads dentro de una sola paginación canónica de Todos
    • mientras eso no exista, Todos sigue respaldado por groups
  • La tabla ya no inventa grupo, cliente/lead ni creacion; esos campos vienen del backend vía GET /leads.
  • Cada fila de cartera conserva groupId además de id; eso permite que selección de filas y asignación trabajen con la semántica real del dominio.
  • La asignación de vendedor dejó de operar por lead y ahora consolida los rows seleccionados en groupIds únicos antes de mutar.
  • El gating de la acción usa cartera.seller.assign + group_assignments.manage.
  • Si employees, lead-assignments o group-assignments fallan al render, la pantalla sigue cargando y solo se degrada la columna vendedor.
  • El feedback visual de asignación en web se muestra como toast temporal abajo al centro, no inline en el header.
  • La UI no depende de router.refresh() para reflejar la asignación:
    • cuando BoostAPI confirma la mutación, web actualiza en caliente las filas ya cargadas del cache local
    • eso aplica tanto a tabs respaldados por groups como a páginas de leads ya precargadas
    • las páginas futuras se sincronizan naturalmente cuando se piden al backend
  • En cartera, asignar vendedor significa crear group-assignments, no lead-assignments.
  • Si se seleccionan varios leads del mismo grupo, web envía una sola asignación para ese grupo.
  • La propagación group -> lead la sigue resolviendo BoostAPI; frontend no recalcula herencia.
  • El listado puede seguir leyendo tanto lead-assignments como group-assignments para renderizar la columna vendedor, pero la mutación canónica desde cartera es por grupo.
  • El listado principal sigue consumiendo GET /leads, por eso la visibilidad del módulo se apoya en permisos efectivos para cartera/leads.
  • El detalle interno /cartera/[id] se trata como detalle base de group:
    • requiere primero acceso al módulo cartera (cartera.leads.list.view o cartera.clients.list.view o leads.view)
    • además requiere permiso efectivo para groups.view
    • usa GET /groups/:id
    • si backend responde 404, web muestra estado de acceso denegado/no visible en vez de renderizar un shell vacío
  • El detalle ya no depende de cartera.leads.detail.view ni de cartera.clients.detail.view; esos permisos legacy no gobiernan esta ruta.
  • Guardar la pestaña Detalles depende de groups.edit.
  • Las acciones internas de fila que abren menú contextual ya no se muestran cuando ese detalle de grupo no es navegable para la sesión actual.
  • El frontend ya no recorta el set de permissions del backend a un subconjunto local; conserva todos los códigos efectivos y los usa como fuente de verdad para gating.
  • Las secciones dinámicas del detalle ya no dependen de un nombre hardcodeado único; web renderiza dinámicamente los custom_field_groups activos que BoostAPI devuelve para la pantalla.
  • Para record_type = group, web muestra las secciones dinámicas reales del grupo dentro de Detalles y las guarda con GET/PUT /groups/:id/custom-field-values.
  • Para record_type = lead, web ya renderiza la estructura de secciones como cards adicionales en el detalle del grupo cuando backend las devuelve, por ejemplo en Discogua:
    • Informacion sobre el negocio
    • Informacion de formularios
  • Esas secciones de lead ya leen y guardan valores reales con:
    • GET /leads/:id/custom-field-values
    • PUT /leads/:id/custom-field-values
  • Regla actual del monorepo:
    • /cartera/[id] sigue siendo una pantalla centrada en group
    • para las secciones record_type = lead, web resuelve un lead principal visible del grupo actual
    • prioridad actual: primero un Cliente (lead con zauru_id), luego el primer lead visible por id
  • Ese supuesto existe para no romper la UI actual del detalle. Si en futuro producto necesita editar varios leads dentro del mismo grupo desde una sola pantalla, eso requerirá una decisión de UX aparte.
  • Web no debe hardcodear nombres de secciones; si backend agrega otro grupo activo para el mismo record_type, debe aparecer automáticamente con el renderer genérico.
  • Web sigue dejando fuera el grupo dinámico legacy Contactos de record_type = group; contactos vive en su dominio propio y no en este bloque.
  • Los cards superiores de Contactos ya no dependen del bloque legacy en custom_fields(record_type = group).
  • Esos cards ahora consumen el dominio oficial de backend:
    • GET /contacts?group_id=...
    • PATCH /contacts/:id
    • GET /contacts/:id/custom-field-values
    • PUT /contacts/:id/custom-field-values
  • La ruta /cartera/[id] ya no necesita fallback de overfetch para contactos cuando BoostAPI soporta group_id; debe traer solo los contactos visibles del grupo actual.
  • Si un grupo trae más de dos contactos, web repite el mismo card existente para cada uno sin cambiar el patrón visual.
  • Campos oficiales conectados hoy en cada card:
    • ID Contacto BD
    • Nombre
    • WhatsApp
    • Teléfono
    • Extension
    • Correo electronico
  • Los extras de record_type = contact se agregan dentro del mismo card usando el renderer genérico de custom fields. Si BoostAPI agrega un campo nuevo para contacto y no existe en la UI base, web lo renderiza al final del card en vez de inventar otro bloque.
  • La respuesta de contacts también devuelve:
    • has_custom_field_values
    • custom_field_values_count
  • Web usa esas banderas para evitar lecturas extra:
    • si has_custom_field_values = false, no pide GET /contacts/:id/custom-field-values
    • si has_custom_field_values = false, tampoco renderiza extras dinámicos del contacto
  • La UI superior de Contactos sigue sin conectar todavía acciones ajenas al contrato actual:
    • Ir a Chat
    • botón X
  • Información General sigue maquetada.
  • Información Fiscal ya consume GET /groups/:id/fiscal-info:
    • llena el dropdown Nombre beneficiario con todos los leads/clientes visibles del grupo
    • renderiza la ficha fiscal del beneficiario activo
    • cuando el usuario cambia el beneficiario, web vuelve a pedir el mismo endpoint con lead_id
    • la UI sigue siendo la misma; solo cambió la fuente de datos
  • Importante: BoostAPI pagina GET /custom-field-groups y GET /custom-fields con limit=10 por default. El monorepo debe pedir explícitamente un limit alto (groups=100, fields=200) o filtrar por custom_field_group_id, porque si consume solo la primera página puede dejar fuera campos reales del cliente y renderizar una sección incompleta.
  • La lectura inicial de valores reales usa:
    • GET /groups/:id/custom-field-values
    • GET /catalogs/currencies
    • GET /catalogs/departments
    • GET /catalogs/municipalities
  • El guardado real usa:
    • PUT /groups/:id/custom-field-values
  • Para media (image, video, pdf) web usa ahora el flujo optimized-only:
    • POST /media/presign-upload
    • PUT directo al upload_url devuelto por backend
    • POST /media/finalize-upload
    • poll a GET /media/uploads/:id
    • refresh de GET /groups/:id/custom-field-values
    • GET /media/presign-download queda para backoffice privado cuando el asset final ya está listo
  • La misma regla aplica para extras de contacto con media:
    • el archivo se mantiene local al seleccionar
    • la subida firmada ocurre hasta Guardar
    • la prioridad ya conectada y verificada en web es el detalle base del grupo
  • El botón Guardar del tab Detalles funciona como orquestador multi-scope.
  • Hoy ya puede persistir, con el mismo click, distintos bloques que no comparten jerarquía:
    • valores del grupo con PUT /groups/:id/custom-field-values
    • valores del lead principal visible del grupo con PUT /leads/:id/custom-field-values
    • contactos visibles y sus extras con PATCH /contacts/:id
  • Regla de implementación actual:
    • cada bloque calcula su diff por separado
    • handleSaveDetails() decide qué endpoints llamar según qué cambió
    • si un bloque no cambió, no se manda request
    • si un bloque falla y otro no, web devuelve error parcial en vez de fingir éxito total
  • Esta misma arquitectura permite conectar después tablas o formularios que no sean custom_fields sin crear otro botón de guardar:
    • cada bloque nuevo aporta su payload y su endpoint
    • el botón central solo coordina el save conjunto
  • Si la sesión tiene contacts.view pero no contacts.edit, los cards superiores se muestran en modo lectura.
  • Si la sesión no tiene contacts.view, la sección superior de Contactos no se renderiza.
  • Regla de UX actual en web:
    • seleccionar un archivo no dispara upload inmediato
    • el archivo queda pendiente en estado local del formulario
    • presign-upload + PUT + finalize-upload ocurren solo cuando el usuario presiona Guardar
    • mientras backend optimiza el archivo, el campo muestra estado de procesamiento hasta que el asset final queda listo
    • durante el PUT inicial, web muestra un estado explícito de “Subiendo archivo. No recargues esta pantalla.”
    • después de finalize-upload, web persiste localmente el media_upload_id del campo para poder reanudar el poll si el usuario recarga mientras backend sigue en pending|processing
    • si el usuario vuelve a cargar la pantalla durante ese tramo, la UI debe reconstruir el card del archivo, seguir animando el estado y continuar el poll hasta ready|failed
    • el poll del upload debe detenerse cuando BoostAPI marque is_terminal = true
    • web puede loguear message, retryable, source_size_bytes y final_size_bytes para diagnóstico, pero no debe exponer esos detalles técnicos al usuario final
    • para render dentro del CRM, si el backend devuelve public_url, web debe preferir esa URL final antes que un presigned download
    • al refrescar GET /groups/:id/custom-field-values, web debe confiar en value.render_state, value.current_asset y value.active_upload
    • processing_initial = no existe asset persistido todavía; solo se muestra el loader del upload
    • processing_replacement = existe un archivo actual y un reemplazo en curso; web puede seguir mostrando el archivo actual, pero marcado explícitamente como archivo actual mientras se procesa el nuevo
    • si el usuario abandona la pantalla sin guardar, no se sube nada al bucket
  • Tipos soportados hoy en Información personalizada del cliente:
    • text
    • paragraph
    • number
    • boolean
    • datetime
    • url
    • single_select
    • multi_select
    • currency
    • department
    • municipality
    • image
    • video
    • pdf
  • El blueprint esperado hoy en BoostAPI para esta sección incluye, al menos:
    • Segmento comercial
    • Resumen de operacion
    • Volumen mensual estimado
    • Requiere credito
    • Fecha de seguimiento
    • Sitio web
    • Tipo de negocio
    • Productos de interes
    • Moneda preferida
    • Departamento
    • Municipio
    • Logo del cliente
    • Video de presentacion
    • Brochure del cliente
  • La dependencia municipality -> department se resuelve en frontend usando settings.depends_on_custom_field_id tal como lo define BoostAPI.
  • Jerarquía operativa de custom fields que web debe asumir:
    • group
    • lead
    • contact
    • importante: client no crea un record_type nuevo; sigue siendo lead con zauru_id != null
  • Los grupos de custom fields siguen siendo secciones con name + record_type + entity_id.
  • Un mismo custom field puede prepararse a futuro para varios contexts/pantallas, pero hoy el ownership real del valor sigue siendo record_type + record_id; web no debe duplicar valores por pantalla.
  • Estado actual de readonly:
    • BoostAPI ya acepta settings.readonly, settings.override_permission_code y settings.contexts
    • web todavía no bloquea inputs por esos flags de forma visual
    • backend sí hace enforcement real en PUT /groups/:id/custom-field-values, PUT /contacts/:id/custom-field-values y POST /media/presign-upload
    • por eso, si un campo readonly se intenta editar sin permiso override, la fuente de verdad hoy es el 403 del backend
  • Regla futura para web/native:
    • si settings.readonly = true, el campo debe seguir visible
    • si settings.readonly = true y no hay override_permission_code, la UI debe dejarlo en solo lectura
    • si settings.readonly = true y sí hay override_permission_code, la UI podrá habilitar edición solo si la sesión trae ese permiso exacto
  • Los contexts existen desde ya como preparación futura, pero cartera web aún no filtra custom fields por pantalla usando ese key.
  • Los custom fields conectados a Zauru siguen siendo solo una familia futura documentada:
    • no existe todavía un tipo especial consumido en web
    • cuando aparezcan, deben tratarse como readonly por default y con Zauru como fuente de verdad principal
  • Si la estructura de custom fields o los catálogos fallan, la pantalla sigue abriendo el grupo y solo degrada la sección dinámica a vacío.
  • La pestaña Facturas ya consume el contrato oficial GET /groups/:id/invoices.
  • La ruta interna sigue representando un group; web no consulta facturas por lead ni habla con Zauru directo.
  • El backend ya resuelve:
    • clientes hijos con zauru_id != null
    • cruce invoices.payee_id = leads.zauru_id
    • orden descendente por día y por factura
    • ventana histórica según invoice_history_months
  • Regla de UI actual:
    • la tabla mantiene la misma estructura visual existente
    • el único cambio visual nuevo es la carga por lotes con Cargar más
    • cada página backend representa un día, no una cantidad fija de filas
  • Mapeo activo en web:
    • Factura:
      • línea 1 = invoice_number
      • línea 2 = payment_term_name
      • línea 3 = payment_expected_at
    • Referencia = reference
    • Beneficiario = payee_name
    • Cantidad items = invoice_details_count
    • Monto de factura = total
    • Saldo = due
    • Ojito:
      • abre el drawer local existente
      • el drawer ahora pide GET /groups/:id/invoices/:invoiceId
      • la URL invoice_url sigue mostrándose al final solo como referencia
      • el listado de líneas del drawer usa los campos normalizados del backend:
        • line_kind
        • code
        • name
        • product_type
        • tracking_name
        • expiration_date
        • expiration_label
      • mientras Intuitiva no exponga serial y lot, web:
        • usa code + name como línea principal
        • deja placeholder suave solo para product_type = 2|3 cuando todavía falta tracking real
        • no inventa un vencimiento si expiration_label sigue nulo
  • Empty states canónicos que web debe mostrar tal cual:
    • disabled_by_entity_setting
    • no_clients_with_zauru_id
    • no_invoices_in_window
  • Semántica importante de esos empty states:
    • siempre describen el group abierto en /cartera/[id]
    • no significan que toda la entidad se haya quedado sin facturas
    • en particular, no_invoices_in_window significa que ese grupo no tiene facturas dentro de la ventana histórica efectiva, aunque otros grupos de la misma entidad sí puedan tenerlas
  • Regla de performance específica:
    • no pedir todas las facturas históricas de un solo golpe
    • cargar solo page=1 al abrir el tab y luego páginas adicionales a demanda
    • conservar los lotes ya cargados mientras el usuario siga en el detalle para evitar refetch innecesario dentro de la misma visita
  • Aunque backend también expone GET /global-settings y PUT /global-settings/invoice_history_months, en esta fase web no tiene UI para editar esa configuración; solo consume la configuración efectiva incluida dentro de la respuesta de facturas.
  • Troubleshooting real:
    • si BoostAPI responde Global setting 'invoice_history_months' not found, el problema no es de mapeo frontend
    • significa que en esa base falta sembrar o crear el catálogo global invoice_history_months
    • la pestaña Facturas no debe intentar recalcular esa ventana histórica desde web como workaround
  • Pendiente de futuro en Facturas:
    • cuando Intuitiva exponga serial y lot desde GraphQL, web solo deberá empezar a pintar:
      • tracking_name
      • expiration_date
      • expiration_label
    • no hará falta rediseñar el drawer; el contrato ya quedó preparado para llenarse sin romper la UI
    • sigue pendiente una UI administrativa para global_settings si negocio decide editar invoice_history_months desde CRM web

Guía para futura implementación en mobile

Section titled “Guía para futura implementación en mobile”
  • Reutilizar @repo/shared-types como contrato fuente para CarteraItem, CarteraAssignSellerInput y CarteraAssignSellerResult.
  • Reutilizar @repo/crm-services y sus métodos getCarteraItems, getSellerOptions, assignSellerToGroups, getGroupCustomFieldValues y saveGroupCustomFieldValues.
  • Mantener la misma semántica de permisos que web:
    • cartera.seller.assign
    • group_assignments.manage
    • groups.view
    • groups.edit
  • Si mobile agrega UI de selección múltiple, debe convertir los rows seleccionados a groupIds únicos antes de mutar.
  • Mobile no debe asumir relación 1:1 entre lead y grupo, ni volver a implementar la herencia de acceso.
  • El paso pendiente para native no es de contrato sino de transporte: cambiar su adapter de createMockCrmServices() a un cliente HTTP autenticado con la sesión mobile/BFF.