Módulo Web Cartera
Módulo Cartera
Section titled “Módulo Cartera”Objetivo
Section titled “Objetivo”Gestionar leads/clientes, filtros y detalle por cliente.
Regla de performance
Section titled “Regla de performance”- 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_typeocustom_field_group_idcuando 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]
Piezas críticas
Section titled “Piezas críticas”CarteraTableFilterDrawerClientDetailShelly tabs derivadas.
Integración actual de tabla
Section titled “Integración actual de tabla”apps/web/app/services/cartera.service.tsresuelve la lista mediantegetServerCrmServices().@repo/crm-servicesconsume la paginación oficial de BoostAPI con elboostTokenguardado en sesión web.- Regla actual de transporte:
TodosusaGET /groups?page=nClientesusaGET /groups?page=nLeadsusaGET /leads?page=n&record_type=lead- importante: por ahora
Clientesno usaGET /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,creacionyvendedor. vendedorse construye conGET /employees,GET /api/lead-assignmentsyGET /api/group-assignments.- La acción de asignar vendedor desde la tabla usa
POST /api/group-assignmentsa través del route handler webapp/api/cartera/seller-assignments/route.ts. - Columnas que siguen en mock temporal:
contacto,ultimaCompra,frecuencia,agendaeinteraccion. - La entrada a
/cartera/[id]ya se alinea al contrato backend de detalle base degroups, no al detalle directo deleads. - Pendiente de futuro para la tabla:
- BoostAPI todavía no expone un payload agregado único para mezclar
groups + leadsdentro de una sola paginación canónica deTodos - mientras eso no exista,
Todossigue respaldado porgroups
- BoostAPI todavía no expone un payload agregado único para mezclar
Cambios recientes
Section titled “Cambios recientes”- La tabla ya no inventa
grupo,cliente/leadnicreacion; esos campos vienen del backend víaGET /leads. - Cada fila de cartera conserva
groupIdademás deid; 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-assignmentsogroup-assignmentsfallan al render, la pantalla sigue cargando y solo se degrada la columnavendedor. - 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
groupscomo a páginas deleadsya precargadas - las páginas futuras se sincronizan naturalmente cuando se piden al backend
Regla operativa de vendedores
Section titled “Regla operativa de vendedores”- En cartera, asignar vendedor significa crear
group-assignments, nolead-assignments. - Si se seleccionan varios leads del mismo grupo, web envía una sola asignación para ese grupo.
- La propagación
group -> leadla sigue resolviendo BoostAPI; frontend no recalcula herencia. - El listado puede seguir leyendo tanto
lead-assignmentscomogroup-assignmentspara renderizar la columnavendedor, pero la mutación canónica desde cartera es por grupo.
Regla operativa de navegación y permisos
Section titled “Regla operativa de navegación y permisos”- 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 degroup:- requiere primero acceso al módulo cartera (
cartera.leads.list.viewocartera.clients.list.viewoleads.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
- requiere primero acceso al módulo cartera (
- El detalle ya no depende de
cartera.leads.detail.viewni decartera.clients.detail.view; esos permisos legacy no gobiernan esta ruta. - Guardar la pestaña
Detallesdepende degroups.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
permissionsdel backend a un subconjunto local; conserva todos los códigos efectivos y los usa como fuente de verdad para gating.
Detalle de custom fields
Section titled “Detalle de custom fields”- Las secciones dinámicas del detalle ya no dependen de un nombre hardcodeado único; web renderiza dinámicamente los
custom_field_groupsactivos que BoostAPI devuelve para la pantalla. - Para
record_type = group, web muestra las secciones dinámicas reales del grupo dentro deDetallesy las guarda conGET/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 negocioInformacion de formularios
- Esas secciones de
leadya leen y guardan valores reales con:GET /leads/:id/custom-field-valuesPUT /leads/:id/custom-field-values
- Regla actual del monorepo:
/cartera/[id]sigue siendo una pantalla centrada engroup- para las secciones
record_type = lead, web resuelve unlead principalvisible del grupo actual - prioridad actual: primero un
Cliente(leadconzauru_id), luego el primerleadvisible 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
Contactosderecord_type = group; contactos vive en su dominio propio y no en este bloque. - Los cards superiores de
Contactosya no dependen del bloque legacy encustom_fields(record_type = group). - Esos cards ahora consumen el dominio oficial de backend:
GET /contacts?group_id=...PATCH /contacts/:idGET /contacts/:id/custom-field-valuesPUT /contacts/:id/custom-field-values
- La ruta
/cartera/[id]ya no necesita fallback de overfetch para contactos cuando BoostAPI soportagroup_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 BDNombreWhatsAppTeléfonoExtensionCorreo electronico
- Los extras de
record_type = contactse 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
contactstambién devuelve:has_custom_field_valuescustom_field_values_count
- Web usa esas banderas para evitar lecturas extra:
- si
has_custom_field_values = false, no pideGET /contacts/:id/custom-field-values - si
has_custom_field_values = false, tampoco renderiza extras dinámicos del contacto
- si
- La UI superior de
Contactossigue sin conectar todavía acciones ajenas al contrato actual:Ir a Chat- botón
X
Información Generalsigue maquetada.Información Fiscalya consumeGET /groups/:id/fiscal-info:- llena el dropdown
Nombre beneficiariocon 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
- llena el dropdown
- Importante: BoostAPI pagina
GET /custom-field-groupsyGET /custom-fieldsconlimit=10por default. El monorepo debe pedir explícitamente unlimitalto (groups=100,fields=200) o filtrar porcustom_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-valuesGET /catalogs/currenciesGET /catalogs/departmentsGET /catalogs/municipalities
- El guardado real usa:
PUT /groups/:id/custom-field-values
- Para media (
image,video,pdf) web usa ahora el flujooptimized-only:POST /media/presign-uploadPUTdirecto alupload_urldevuelto por backendPOST /media/finalize-upload- poll a
GET /media/uploads/:id - refresh de
GET /groups/:id/custom-field-values GET /media/presign-downloadqueda 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
Guardardel tabDetallesfunciona 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
- valores del grupo con
- 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_fieldssin 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.viewpero nocontacts.edit, los cards superiores se muestran en modo lectura. - Si la sesión no tiene
contacts.view, la sección superior deContactosno 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-uploadocurren solo cuando el usuario presionaGuardar- mientras backend optimiza el archivo, el campo muestra estado de procesamiento hasta que el asset final queda listo
- durante el
PUTinicial, web muestra un estado explícito de “Subiendo archivo. No recargues esta pantalla.” - después de
finalize-upload, web persiste localmente elmedia_upload_iddel campo para poder reanudar el poll si el usuario recarga mientras backend sigue enpending|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_bytesyfinal_size_bytespara 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 envalue.render_state,value.current_assetyvalue.active_upload processing_initial= no existe asset persistido todavía; solo se muestra el loader del uploadprocessing_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:textparagraphnumberbooleandatetimeurlsingle_selectmulti_selectcurrencydepartmentmunicipalityimagevideopdf
- El blueprint esperado hoy en BoostAPI para esta sección incluye, al menos:
Segmento comercialResumen de operacionVolumen mensual estimadoRequiere creditoFecha de seguimientoSitio webTipo de negocioProductos de interesMoneda preferidaDepartamentoMunicipioLogo del clienteVideo de presentacionBrochure del cliente
- La dependencia
municipality -> departmentse resuelve en frontend usandosettings.depends_on_custom_field_idtal como lo define BoostAPI. - Jerarquía operativa de custom fields que web debe asumir:
groupleadcontact- importante:
clientno crea unrecord_typenuevo; sigue siendoleadconzauru_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 siendorecord_type + record_id; web no debe duplicar valores por pantalla. - Estado actual de
readonly:- BoostAPI ya acepta
settings.readonly,settings.override_permission_codeysettings.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-valuesyPOST /media/presign-upload - por eso, si un campo readonly se intenta editar sin permiso override, la fuente de verdad hoy es el
403del backend
- BoostAPI ya acepta
- Regla futura para web/native:
- si
settings.readonly = true, el campo debe seguir visible - si
settings.readonly = truey no hayoverride_permission_code, la UI debe dejarlo en solo lectura - si
settings.readonly = truey sí hayoverride_permission_code, la UI podrá habilitar edición solo si la sesión trae ese permiso exacto
- si
- Los
contextsexisten 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.
Detalle de facturas
Section titled “Detalle de facturas”- La pestaña
Facturasya consume el contrato oficialGET /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
- clientes hijos con
- 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
- línea 1 =
Referencia=referenceBeneficiario=payee_nameCantidad items=invoice_details_countMonto de factura=totalSaldo=dueOjito:- abre el drawer local existente
- el drawer ahora pide
GET /groups/:id/invoices/:invoiceId - la URL
invoice_urlsigue mostrándose al final solo como referencia - el listado de líneas del drawer usa los campos normalizados del backend:
line_kindcodenameproduct_typetracking_nameexpiration_dateexpiration_label
- mientras Intuitiva no exponga
serialylot, web:- usa
code + namecomo línea principal - deja placeholder suave solo para
product_type = 2|3cuando todavía falta tracking real - no inventa un vencimiento si
expiration_labelsigue nulo
- usa
- Empty states canónicos que web debe mostrar tal cual:
disabled_by_entity_settingno_clients_with_zauru_idno_invoices_in_window
- Semántica importante de esos empty states:
- siempre describen el
groupabierto en/cartera/[id] - no significan que toda la entidad se haya quedado sin facturas
- en particular,
no_invoices_in_windowsignifica que ese grupo no tiene facturas dentro de la ventana histórica efectiva, aunque otros grupos de la misma entidad sí puedan tenerlas
- siempre describen el
- Regla de performance específica:
- no pedir todas las facturas históricas de un solo golpe
- cargar solo
page=1al 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-settingsyPUT /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
Facturasno debe intentar recalcular esa ventana histórica desde web como workaround
- si BoostAPI responde
- Pendiente de futuro en
Facturas:- cuando Intuitiva exponga
serialylotdesde GraphQL, web solo deberá empezar a pintar:tracking_nameexpiration_dateexpiration_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_settingssi negocio decide editarinvoice_history_monthsdesde CRM web
- cuando Intuitiva exponga
Guía para futura implementación en mobile
Section titled “Guía para futura implementación en mobile”- Reutilizar
@repo/shared-typescomo contrato fuente paraCarteraItem,CarteraAssignSellerInputyCarteraAssignSellerResult. - Reutilizar
@repo/crm-servicesy sus métodosgetCarteraItems,getSellerOptions,assignSellerToGroups,getGroupCustomFieldValuesysaveGroupCustomFieldValues. - Mantener la misma semántica de permisos que web:
cartera.seller.assigngroup_assignments.managegroups.viewgroups.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.
