feat: mis cobranzas, recordatorios de pago y agente IA con acciones

- PlanPago.vendedorId + filtro ?mios=1; toggle Mis planes/Todos en /pay
- Motor de recordatorios: próximos a vencer (3d) y vencidos (re-cada 7d),
  canal Monday Actividad + stub WhatsApp listo para WA CRM, log persistente,
  cron diario 9:30, run manual con dry-run, historial en /api/cobranza/recordatorios
- Agente IA: memoria por usuario (conversaciones aisladas) y tool calling:
  cotizar_unidad (motor real por desarrollo) y buscar_unidad (inventario Monday)
- Tests: api 261, cobranza 75, agente-ia 13
This commit is contained in:
2026-08-28 08:07:12 +00:00
parent 59944668a8
commit aa27e89c75
18 changed files with 1437 additions and 17 deletions

View File

@@ -14,7 +14,8 @@ import {
YAxis,
} from 'recharts'
import type { StatusPlan } from '../types'
import { listarPlanes, type ResultadoPlanes } from '../services/repository'
import { listarPlanes, miosPorDefecto, type ResultadoPlanes } from '../services/repository'
import { getSesion } from '../services/auth'
import {
alCorriente,
avancePlan,
@@ -55,11 +56,17 @@ export default function DashboardPage() {
const paramOrden = searchParams.get('sort')
const orden: ColumnaOrden | null = esColumnaOrden(paramOrden) ? paramOrden : null
const dir: 'asc' | 'desc' = searchParams.get('dir') === 'desc' ? 'desc' : 'asc'
// Toggle "Mis planes / Todos": persiste en la URL (?vista=mios|todos); sin
// param, los vendedores arrancan en "Mis planes" y el resto en "Todos"
const paramVista = searchParams.get('vista')
const mios = paramVista !== null ? paramVista === 'mios' : miosPorDefecto(getSesion()?.usuario.rol)
const hoy = useMemo(() => new Date(), [])
useEffect(() => {
let activo = true
listarPlanes()
setResultado(null)
setErrorCarga(null)
listarPlanes(undefined, mios)
.then((r) => {
if (activo) setResultado(r)
})
@@ -69,7 +76,7 @@ export default function DashboardPage() {
return () => {
activo = false
}
}, [])
}, [mios])
const planes = useMemo(() => resultado?.planes ?? [], [resultado])
@@ -287,6 +294,29 @@ export default function DashboardPage() {
{/* Filtros */}
<div className="flex flex-wrap items-center gap-4">
{/* Toggle Mis planes / Todos (?mios=1 filtra en el API por el vendedor del token) */}
<div className="flex border border-neutral-300" role="group" aria-label="Alcance de planes">
<button
type="button"
onClick={() => actualizarParams({ vista: mios ? null : 'mios' })}
aria-pressed={mios}
className={`px-4 py-2.5 text-xs font-bold uppercase tracking-widest transition-colors duration-150 ${
mios ? 'bg-accent text-white' : 'bg-white text-neutral-600 hover:text-neutral-900'
}`}
>
Mis planes
</button>
<button
type="button"
onClick={() => actualizarParams({ vista: mios ? 'todos' : null })}
aria-pressed={!mios}
className={`px-4 py-2.5 text-xs font-bold uppercase tracking-widest transition-colors duration-150 ${
mios ? 'bg-white text-neutral-600 hover:text-neutral-900' : 'bg-accent text-white'
}`}
>
Todos
</button>
</div>
<label htmlFor="filtro-busqueda" className="sr-only">
Buscar por cliente o unidad
</label>

View File

@@ -335,3 +335,44 @@ describe('repository async (API primero, fallback localStorage)', () => {
expect(guardados[0].status).toBe('CANCELADO')
})
})
describe('mis cobranzas (toggle Mis planes / Todos)', () => {
it('rutaListadoPlanes agrega ?mios=1 solo cuando se piden los míos', async () => {
const { rutaListadoPlanes } = await import('./repository')
expect(rutaListadoPlanes(true)).toBe('/planes?mios=1')
expect(rutaListadoPlanes(false)).toBe('/planes')
})
it('miosPorDefecto: vendedor arranca en "Mis planes"; el resto en "Todos"', async () => {
const { miosPorDefecto } = await import('./repository')
expect(miosPorDefecto('vendedor')).toBe(true)
expect(miosPorDefecto('admin')).toBe(false)
expect(miosPorDefecto('coordinador')).toBe(false)
expect(miosPorDefecto('corporativo')).toBe(false)
expect(miosPorDefecto(null)).toBe(false)
expect(miosPorDefecto(undefined)).toBe(false)
})
it('listarPlanes con mios=true llama al API con ?mios=1', async () => {
const fetchMock = fetchQue(async () => respuestaJson([planApi()]))
vi.stubGlobal('fetch', fetchMock)
const r = await listarPlanes(crearStorageStub(), true)
expect(r.origen).toBe('api')
expect(fetchMock).toHaveBeenCalledWith(
'http://192.168.10.123:3000/api/planes?mios=1',
expect.objectContaining({ headers: { 'Content-Type': 'application/json' } }),
)
})
it('listarPlanes con mios=true cae a localStorage igual que sin el filtro', async () => {
vi.stubGlobal('fetch', fetchSinRed())
vi.spyOn(console, 'warn').mockImplementation(() => {})
const r = await listarPlanes(crearStorageStub(), true)
expect(r.origen).toBe('local') // el respaldo local no tiene vendedorId
expect(r.planes.length).toBeGreaterThan(0)
})
})

View File

@@ -172,14 +172,34 @@ function planesLocalesSinSembrar(storage: StorageLike): PlanPago[] {
return planes.map((p) => (p.notas ? p : { ...p, notas: [] }))
}
/**
* Ruta del listado de planes: con `mios` agrega ?mios=1 (el API filtra por el
* sub del token; admin/coordinador ven todo de todos modos).
*/
export function rutaListadoPlanes(mios: boolean): string {
return mios ? '/planes?mios=1' : '/planes'
}
/**
* Vista inicial del toggle "Mis planes / Todos": los vendedores arrancan en
* "Mis planes"; el resto de roles (admin, coordinador, corporativo) en "Todos".
*/
export function miosPorDefecto(rol: string | null | undefined): boolean {
return rol === 'vendedor'
}
/**
* GET /planes. Ante error de red cae a localStorage (sembrando si está vacío).
* Si el API responde vacío pero hay planes locales, los conserva solo localmente
* (NO se migran automáticamente) y lo señala en `planesSoloLocales`.
* `mios` solo afecta la llamada al API; el respaldo local no tiene vendedorId.
*/
export async function listarPlanes(storage: StorageLike = storageDefault()): Promise<ResultadoPlanes> {
export async function listarPlanes(
storage: StorageLike = storageDefault(),
mios = false,
): Promise<ResultadoPlanes> {
try {
const planes = (await request<PlanApi[]>('/planes')).map(desdeApi)
const planes = (await request<PlanApi[]>(rutaListadoPlanes(mios))).map(desdeApi)
if (planes.length === 0) {
const locales = planesLocalesSinSembrar(storage)
if (locales.length > 0) {