# Sesión 2026-04-25 — Módulo Despacho, Tareas y Papelería Sesión enfocada en **features nuevos** del fork `Horux_despacho`. Los fixes fiscales del día anterior están en `docs/plans/2026-04-24-session-fixes-and-features.md`. --- ## Índice 1. [Banner "CSD recién tramitado"](#1-banner-csd-recién-tramitado) 2. [Preferencias de notificación por contribuyente](#2-preferencias-de-notificación-por-contribuyente) 3. [Tareas operativas (recurrentes)](#3-tareas-operativas-recurrentes) 4. [Papelería de Trabajo](#4-papelería-de-trabajo) 5. [Módulo "Despacho" — 3 páginas](#5-módulo-despacho-3-páginas) 6. [Selector de periodo global](#6-selector-de-periodo-global) 7. [Asignación de supervisor desde /usuarios](#7-asignación-de-supervisor-desde-usuarios) 8. [Ajustes de UI](#8-ajustes-de-ui) 9. [Migraciones aplicadas](#9-migraciones-aplicadas) 10. [Pendientes](#10-pendientes) --- ## 1. Banner "CSD recién tramitado" ### Problema El SAT tarda 24-72h en propagar un CSD nuevo a la Lista de Contribuyentes Obligados (LCO). Durante esa ventana, intentos de emisión vía Facturapi fallan con mensajes tipo "RFC no encontrado en LCO". El user pierde tiempo intentando emitir y abre tickets innecesarios. ### Solución Banner contextual en `/facturacion` que aparece SOLO si hubo un rechazo del SAT con patrón LCO en las últimas 24h. ### Archivos - **Migración 033**: `facturapi_orgs.last_lco_rejection_at timestamptz`. - `apps/api/src/controllers/facturacion.controller.ts`: - Helper `isLcoRejection(message)` con regex contra patrones SAT. - Helper `markLcoRejection(pool, contribuyenteId)` upsertea timestamp. - En el catch de `emitir()` se dispara la marca cuando aplica. - Endpoint `getLcoStatus` retorna `{ hasRecentLcoRejection, rejectedAt }`. - `apps/api/src/routes/facturacion.routes.ts`: `GET /facturacion/lco-status`. - `apps/web/app/(dashboard)/facturacion/page.tsx`: banner amber al inicio del form, polling cada 15min. ### Mensaje > CSD aún en proceso de validación — espera 24 horas antes de emitir tu factura. ### Auto-expiración La condición es `rejectedAt > NOW() - 24h`. Pasadas las 24h el banner desaparece sin necesidad de cron. --- ## 2. Preferencias de notificación por contribuyente ### Problema Los emails informativos se enviaban siempre. El user quería poder desactivar tipos específicos por contribuyente sin afectar a otros. ### Solución JSONB `email_preferences` en `contribuyentes`; los handlers de envío respetan la preferencia antes de mandar. ### Archivos - **Migración 034**: `contribuyentes.email_preferences jsonb DEFAULT '{}'`. - `apps/api/src/services/notification-preferences.service.ts`: - `EMAIL_TYPES = ['documento_subido', 'weekly_update', 'subscription_expiring', 'recordatorio_fiscal']`. - `getContribuyenteEmailPreferences()` con default true. - `setContribuyenteEmailPreferences()` merge sobre JSONB. - `apps/api/src/services/notify-upload.service.ts`: check `prefs.documento_subido` antes de enviar. - `apps/api/src/controllers/notification-preferences.controller.ts`: `listPreferences` + `updatePreferences`. - `apps/api/src/routes/notification-preferences.routes.ts`: `GET / PUT /api/notificaciones`. - `apps/web/app/(dashboard)/configuracion/notificaciones/page.tsx`: toggles por tipo de email por contribuyente, optimistic update. ### Tipos de email | Tipo | Estado | Notas | |---|---|---| | `documento_subido` | ✅ activo | check en `notify-upload.service.ts` | | `weekly_update` | ⏳ próximamente | el job es tenant-wide, requiere refactor | | `subscription_expiring` | ⏳ próximamente | no es per-contribuyente hoy | | `recordatorio_fiscal` | ⏳ placeholder | para futuras alertas fiscales | Los toggles "Próximamente" se persisten pero no bloquean el envío todavía. Cuando se implemente cada email per-contribuyente, basta con agregar el check. ### Default Todo activado. Si la columna está vacía o le falta una key, asumimos `true` para preservar comportamiento previo. --- ## 3. Tareas operativas (recurrentes) Sistema completo de tareas operativas del despacho por contribuyente, distinto del flujo de obligaciones fiscales (que ya existía). ### Características - **Recurrencias**: semanal, quincenal, mensual, bimestral, trimestral, semestral, anual. - **Día de vencimiento**: día de la semana (1-7) o día del mes (1-31) según recurrencia. - **Estados**: pendiente / completada (en BD); "atrasada" se calcula en frontend. - **Permisos**: - Rol `cliente`: bloqueado en TODOS los endpoints (403). - Flag `solo_supervisor_completa`: si está en true, solo owner/cfo/supervisor pueden marcarla completa. ### Archivos - **Migración 035**: ```sql tareas_catalogo (id, contribuyente_id, nombre, descripcion, recurrencia, dia_semana, dia_mes, solo_supervisor_completa, es_default, active, orden, created_at) tarea_periodos (id, tarea_id, periodo, fecha_limite, completada, completada_at, completada_por, notas, created_at) ``` - `apps/api/src/services/tareas.service.ts`: - CRUD del catálogo. - **Materialización lazy**: cuando el frontend pide tareas con periodo actual, el backend genera periodos faltantes solo del presente en adelante (códigos `2025-W12`, `2025-01`, `2025-B1`, `2025-Q1`, `2025-S1`, `2025`). - `seedTareasDefault()` con 4 tareas: estados de cuenta (día 5), conciliación (día 10), contabilización (día 14), revisión fiscal preliminar (día 15, `solo_supervisor_completa=true`). - `getEventosTareasParaCalendario()` retorna shape `EventoFiscal` con `tipo: 'tarea'`. - `contarTareasProximasVencer()` para alertas auto. - `getAuxiliarUserIdDeContribuyente()` resuelve cartera → auxiliar. - `apps/api/src/controllers/tareas.controller.ts`: - Bloqueo rol cliente. - `notifyAuxiliarTareaCompletada()`: dispara email cuando una tarea con `solo_supervisor_completa=true` se marca completa por supervisor/owner — el auxiliar recibe aviso. - `apps/api/src/routes/tareas.routes.ts`: ``` GET /api/tareas — list con periodos materializados POST /api/tareas — crear PATCH /api/tareas/:id — update DELETE /api/tareas/:id — soft delete (active=false) POST /api/tareas/seed — seed defaults POST /api/tareas/periodo/:id/completar DELETE /api/tareas/periodo/:id/completar ``` - `apps/api/src/services/email/templates/tarea-completada.ts`: template para notificar al auxiliar. - `apps/api/src/services/alertas-auto.service.ts`: `alertaTareasProximasVencer()` (≤3 días, prioridad media), enchufada en `generarAlertasAutomaticas`. - `apps/api/src/controllers/calendario.controller.ts`: incluye tareas en el GET de eventos cuando hay contribuyente seleccionado y rol no es cliente. - `packages/shared/src/types/calendario.ts`: agregado `'tarea'` a `TipoEvento`. ### Frontend - `apps/web/components/obligaciones/tareas-tab.tsx`: pestaña "Tareas" en `/configuracion/obligaciones` con tabla, modal crear/editar, check pendiente/completada. - `apps/web/app/(dashboard)/configuracion/obligaciones/page.tsx`: tabs "Obligaciones" / "Tareas". ### Flujo email "tarea revisada" 1. Auxiliar trabaja en algo del contribuyente. 2. Supervisor/owner marca como completada la tarea `solo_supervisor_completa=true` (ej. "Revisión fiscal preliminar"). 3. Backend resuelve `auxiliar_user_id` desde la cartera del contribuyente. 4. Email al auxiliar con el detalle (quién marcó, periodo, notas). --- ## 4. Papelería de Trabajo Sección nueva en `/documentos` para subir archivos de trabajo del despacho con flujo opcional de aprobación. ### Características - **No accesible para usuarios rol cliente** (oculta + 403 en API). - **Formatos permitidos**: PDF, Word (doc/docx), Excel (xls/xlsx). - **Tamaño máximo**: 5 MB por archivo (validado backend + frontend). - **Periodo**: mes + año (selector en el modal de subida). - **Aprobación opcional**: checkbox al subir; si está activo, queda en estado `pendiente` y solo owner/supervisor pueden aprobar/rechazar. - **Comentario opcional al rechazar**. ### Archivos - **Migración 036**: ```sql papeleria_trabajo (id, contribuyente_id, nombre, descripcion, archivo bytea, archivo_filename, archivo_mime, archivo_size, anio, mes, requiere_aprobacion, estado, aprobado_por, aprobado_at, comentario_rechazo, subido_por, created_at) ``` Estados válidos: `pendiente | aprobado | rechazado`. NULL si no requiere aprobación. - `apps/api/src/services/papeleria.service.ts`: - Validación MIME + tamaño. - CRUD: `uploadPapeleria`, `listPapeleria` (filtros año/mes/estado), `downloadArchivo`, `aprobar`, `rechazar`, `eliminar`. - `aprobar`/`rechazar` validan rol owner/cfo/supervisor. - `apps/api/src/controllers/papeleria.controller.ts`: - Bloqueo rol cliente con `rejectClienteRole()`. - Schema Zod estricto. - **Notificaciones**: - Al subir con `requiere_aprobacion=true`: email a owners + supervisores. - Al aprobar/rechazar: email al uploader (excepto si es el mismo). - Excepción al uploader: si el aprobador es el mismo user que subió (ej. owner que sube su propia papelería), no se auto-notifica. - `apps/api/src/services/email/templates/papeleria.ts`: 2 templates (`papeleriaAprobacionRequeridaEmail` + `papeleriaDecisionEmail`). - `apps/api/src/routes/papeleria.routes.ts`: ``` GET /api/papeleria — list con filtros POST /api/papeleria — upload (base64 en JSON body) GET /api/papeleria/:id/download — descarga binaria POST /api/papeleria/:id/aprobar POST /api/papeleria/:id/rechazar — body: { comentario? } DELETE /api/papeleria/:id ``` ### Frontend - `apps/web/components/documentos/papeleria-tab.tsx`: pestaña con filtros (año, mes, estado), modal de upload con base64 conversion, badges de estado, modal de rechazo con comentario. - `apps/web/app/(dashboard)/documentos/page.tsx`: pestaña condicional `if (user.role !== 'cliente')`. --- ## 5. Módulo "Despacho" — 3 páginas Reemplazo del módulo `/pendientes` con sub-secciones específicas por rol. **Sidebar** renombrado: "Pendientes" → "Despacho". ### Estructura ``` /despachos → redirect según rol /despachos/contribuyentes → owner/cfo (métricas globales) /despachos/mis-asignados → owner/cfo/supervisor/auxiliar /despachos/equipo → owner/cfo/supervisor (jerárquico) ``` ### Sub-nav común - `apps/web/components/despachos/despacho-subnav.tsx`: tabs visibles según rol del user (filtrado en frontend). - `defaultDespachoPathForRole(role)`: helper para el redirect en `/despachos`. ### 5a. `/despachos/contribuyentes` (owner-only) 7 cards de métricas globales del despacho, todas filtrables por periodo: | Card | Fuente | Notas | |---|---|---| | Total contribuyentes | `entidades_gestionadas` activos | independiente del periodo | | Última extracción SAT | `MAX(sat_sync_jobs.completed_at)` BD central | independiente del periodo | | Progreso del mes (%) | `obligaciones+tareas completadas / total` | barra de color | | Declaraciones presentadas | `declaraciones_provisionales` con `created_at` en periodo | | | Declaraciones pagadas | subset con `pdf_pago IS NOT NULL` | | | Declaraciones atrasadas | obligaciones de declaración pendientes con `periodo < seleccionado` | heurística por categoría | | Tareas atrasadas | tareas pendientes con `fecha_limite < primer día` | | **Heurística "es declaración"** (sin flag explícito en el catálogo): ```sql LOWER(categoria) ~ 'mensual|anual|declarac' OR LOWER(nombre) LIKE '%declarac%' ``` Si crece la base se puede agregar columna `es_declaracion boolean` y ajustar. ### 5b. `/despachos/mis-asignados` Tabla con contribuyentes filtrada por cartera del user: | Rol | Filtro | |---|---| | owner / cfo | TODOS los contribuyentes | | supervisor | contribuyentes en sus carteras top-level + subcarteras | | auxiliar | contribuyentes en carteras donde es `auxiliar_user_id` | **Columnas**: - Contribuyente (link → `/configuracion/obligaciones` con contribuyente preseleccionado). - Cartera. - **Avance** (barra de color verde≥80% / ámbar 50-79% / rojo <50% + porcentaje numérico). - **Atrasos** — badge agregado de obligaciones+tareas atrasadas; "Al día" si no tiene. - Obl. periodo (completadas / pendientes). - Tareas periodo (completadas / pendientes). **Resaltado de filas** con atrasos: `bg-red-50/50` para llamar la atención. **Orden**: por suma de atrasos descendente (rezagados arriba). ### 5c. `/despachos/equipo` Vista jerárquica supervisor → auxiliares. Click en un supervisor **expande** debajo a sus auxiliares. **Resolución de la relación supervisor → auxiliar** (UNION de 3 fuentes): ```sql 1. carteras top-level con auxiliar_user_id + supervisor_user_id directos 2. subcarteras con auxiliar_user_id; supervisor del cartera padre 3. tabla legacy auxiliar_supervisores (fallback / override desde /usuarios) ``` **Sección "Auxiliares sin supervisor asignado"**: si después del UNION queda un auxiliar activo (rol `auxiliar` en `tenant_memberships`) sin supervisor, aparece en una sección al final con icono de advertencia ámbar — solo el owner la ve. **Permisos**: - owner/cfo: ve TODOS los supervisores con sus auxiliares + huérfanos. - supervisor: ve solo a sí mismo con sus auxiliares (sin huérfanos). **Métricas por miembro**: - Contribuyentes asignados. - Avance del periodo (barra de color, igual que mis-asignados). - Atrasos (obligaciones + tareas). ### Backend - `apps/api/src/services/despacho-stats.service.ts`: - `getContribuyentesStats(pool, tenantId, año?, mes?)`. - `getMisAsignados(pool, userId, userRole, año?, mes?)`. - `getEquipoStats(pool, userId, userRole, tenantId, año?, mes?)`. - `apps/api/src/controllers/despacho-stats.controller.ts`: 3 endpoints con guards de rol. - `apps/api/src/routes/despacho-stats.routes.ts`: mountea en `/api/despachos`. --- ## 6. Selector de periodo global ### Problema Antes solo el mes en curso era visible. El user quiere navegar a otros periodos para revisar avance histórico. ### Solución Wrapper `` alrededor del `` de `@horux/shared-ui` (mismo patrón visual que `/dashboard`, `/impuestos`, `/reportes`). Persiste selección en `localStorage` via Zustand. ### Archivos - `apps/web/stores/periodo-store.ts`: - `fechaInicio` / `fechaFin` (strings ISO YYYY-MM-DD). - Default: primer y último día del mes en curso. - Helper `añoMesFromFechaInicio()` para queries que usan año/mes. - `apps/web/components/periodo-selector.tsx`: wrapper que se pasa como children al `
` en cada página `/despachos/*`. ### Endpoints que aceptan el filtro - `GET /api/despachos/contribuyentes-stats?año=YYYY&mes=MM` - `GET /api/despachos/mis-asignados?año=YYYY&mes=MM` - `GET /api/despachos/equipo-stats?año=YYYY&mes=MM` Default: mes en curso si no vienen los params. --- ## 7. Asignación de supervisor desde /usuarios ### Problema Al invitar un auxiliar se pedía supervisor, pero **no había forma de cambiarlo después**. Al abrir el modal aparecía "none" porque el endpoint solo leía de `auxiliar_supervisores` ignorando carteras. ### Solución Endpoint que consulta TODAS las fuentes de la relación supervisor↔auxiliar y un modal "Supervisor" en `/usuarios`. ### Archivos backend - `apps/api/src/controllers/usuarios.controller.ts`: - `getSupervisor`: UNION priorizada — `auxiliar_supervisores` (1) → cartera directa (2) → cartera padre de subcartera (3). El primer resultado gana. - `updateSupervisor`: upsert en `auxiliar_supervisores` (override explícito sobre la cartera). Pasar `supervisorUserId: null` borra la asignación. - `apps/api/src/routes/usuarios.routes.ts`: ``` GET /api/usuarios/:id/supervisor PUT /api/usuarios/:id/supervisor ``` ### Frontend - Botón "Supervisor" (icono `UserCheck`) en cada fila de auxiliar, visible solo en tenant tipo despacho y para admins. - Modal con `