# 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 `<PeriodoSelector />` alrededor del `<PeriodSelector />` 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 `<Header>` 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 `<Select>` de supervisores + opción "Sin supervisor
  asignado".

### Trade-off
La tabla `auxiliar_supervisores` actúa como **override** sobre la
cartera. Si después de asignar via cartera, el owner cambia el
supervisor desde `/usuarios`, queda guardado en `auxiliar_supervisores`
y eso prevalece. Si quieres revertir, borrar la fila desde
`/usuarios` (opción "Sin supervisor asignado").

---

## 8. Ajustes de UI

### 8a. Sidebar sin selector de contribuyente
El `<ContribuyenteSelector />` ya estaba en el header — duplicado en
sidebar agregaba ruido. Removido del sidebar (`apps/web/components/layouts/sidebar.tsx`).

### 8b. Selector contribuyente oculto en `/despachos/*`
Las 3 vistas de Despacho son cross-contribuyente (métricas agregadas,
mis asignados es la lista completa, equipo es jerarquía). Filtrar por
contribuyente individual rompe el propósito.

`apps/web/components/contribuyente-selector.tsx`: array `HIDDEN_PATHS`
con `/despachos`. Si el pathname matchea, retorna `null` antes de
renderizar.

### 8c. Header
Selector de periodo se pasa como **children del `<Header>`** (al lado
del título), igual que en `/dashboard` — no en el lado derecho.

### 8d. PageNotificaciones centrada
`<main className="... mx-auto">` agregado.

### 8e. Configuración Obligaciones Fiscales — Header
Faltaba el `<Header />` en la página `/configuracion/obligaciones` así
que perdía el selector global. Agregado.

---

## 9. Migraciones aplicadas

| # | Archivo | Tabla / cambio |
|---|---|---|
| 033 | `033_facturapi_orgs_lco_rejection.sql` | `facturapi_orgs.last_lco_rejection_at` |
| 034 | `034_contribuyentes_email_preferences.sql` | `contribuyentes.email_preferences jsonb` |
| 035 | `035_tareas.sql` | `tareas_catalogo` + `tarea_periodos` |
| 036 | `036_papeleria_trabajo.sql` | `papeleria_trabajo` |

Aplicadas vía `pnpm db:migrate-tenants` en ambos tenants
(`DESPACHO_MO7JE8BZ_VDOPR` y `DESPACHO_MO3NI6U8_B9VGG`).

---

## 10. Pendientes

### Tareas
- Soporte de tipo "one-time" (no recurrente). Hoy todas son recurrentes.
- Asignación explícita de tarea a un user específico (hoy se asume
  cartera).

### Notificaciones
- Implementar el envío per-contribuyente para `weekly_update`,
  `subscription_expiring`, `recordatorio_fiscal` (los 3 toggles
  "Próximamente"). Requiere refactor del cron de weekly-update para que
  itere por contribuyente en lugar de por tenant.

### Métricas Despacho
- "Es declaración" en obligaciones: agregar flag explícito al catálogo
  (`obligaciones_contribuyente.es_declaracion boolean`) y migrar la
  heurística actual de regex.
- Drill-down: click en cada card del módulo Contribuyentes lleva a una
  vista detallada (ej. listado de declaraciones atrasadas con
  contribuyente y periodo).

### Equipo
- Mostrar % avance histórico (gráfica) de cada miembro a lo largo de
  varios periodos.
- Asignar manualmente contribuyentes a un user desde la vista de equipo
  (hoy solo via carteras).

### Papelería
- Versionado: si el user sube un nuevo archivo del mismo "concepto",
  guardar histórico de versiones (hoy crea fila independiente cada vez).
- Filtros por uploader.

### Bugs reportados (por revisar)
- **Visibilidad de auxiliares en carteras de supervisores**: en la
  pantalla de carteras, el supervisor está viendo a auxiliares que NO
  están asignados a él. Debe filtrar para que cada supervisor solo vea
  los auxiliares en sus propias carteras (top-level + subcarteras
  donde es supervisor).
- **Tareas completadas > pendientes en módulo Despacho**: las métricas
  muestran más tareas completadas de las que existen pendientes —
  aritméticamente raro. Posible causa: el conteo de "completadas"
  incluye periodos históricos mientras que "pendientes" se limita al
  periodo seleccionado. Validar que ambos lados del ratio usen el
  mismo universo (mes filtrado) o documentar explícitamente la
  asimetría si es intencional.
- **Drill-down de alerta TipoRelacion sospechoso incompleto**: no
  están saliendo todos los CFDIs tipo E con posible TipoRelacion
  errónea en el listado. Revisar la heurística en
  `apps/api/src/services/alertas-auto.service.ts` (constante
  `SOSPECHOSA_TIPO_RELACION_WHERE` y reuso en
  `getCfdisTipoRelacionSospechosa` del controller). Posibles causas:
  (a) operador `&&` de PostgreSQL sobre `string_to_array` no detecta
  algunos casos por case-sensitivity o caracteres extraños en
  `cfdis_relacionados`; (b) el filtro excluye E con
  `cfdi_tipo_relacion=NULL` cuando algunas inconsistencias podrían
  tener ese estado; (c) RFC del contribuyente no se aplica
  uniformemente en el JOIN.
