feat: API Gateway (Express 5 + Prisma + Monday) y conexion de apps al API
- api-gateway: servicio Monday (retry 429/complejidad, paginacion cursor, column IDs por titulo), endpoints unidades/cotizaciones/clientes/planes, webhook con handshake challenge, Prisma SQLite dev, 55 tests - cotizador: inventario desde /api/unidades con fallback a seed - alta-clientes: POST /api/clientes + upload multipart de documentos - cobranza: persistencia via API con respaldo offline en localStorage - .env.example con board IDs del workspace Frasa-CAS
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Link, useSearchParams } from 'react-router-dom'
|
||||
import {
|
||||
Bar,
|
||||
@@ -13,8 +13,8 @@ import {
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from 'recharts'
|
||||
import type { PlanPago, StatusPlan } from '../types'
|
||||
import { cargarPlanes } from '../services/repository'
|
||||
import type { StatusPlan } from '../types'
|
||||
import { listarPlanes, type ResultadoPlanes } from '../services/repository'
|
||||
import {
|
||||
alCorriente,
|
||||
avancePlan,
|
||||
@@ -45,7 +45,8 @@ function claveMes(fecha: Date): string {
|
||||
}
|
||||
|
||||
export default function DashboardPage() {
|
||||
const [planes] = useState<PlanPago[]>(() => cargarPlanes())
|
||||
const [resultado, setResultado] = useState<ResultadoPlanes | null>(null)
|
||||
const [errorCarga, setErrorCarga] = useState<string | null>(null)
|
||||
// Filtros y ordenamiento persistidos en la URL para que sean compartibles
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
const busqueda = searchParams.get('q') ?? ''
|
||||
@@ -56,6 +57,22 @@ export default function DashboardPage() {
|
||||
const dir: 'asc' | 'desc' = searchParams.get('dir') === 'desc' ? 'desc' : 'asc'
|
||||
const hoy = useMemo(() => new Date(), [])
|
||||
|
||||
useEffect(() => {
|
||||
let activo = true
|
||||
listarPlanes()
|
||||
.then((r) => {
|
||||
if (activo) setResultado(r)
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
if (activo) setErrorCarga(e instanceof Error ? e.message : 'Error al cargar los planes')
|
||||
})
|
||||
return () => {
|
||||
activo = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
const planes = useMemo(() => resultado?.planes ?? [], [resultado])
|
||||
|
||||
const actualizarParams = (cambios: Record<string, string | null>) => {
|
||||
setSearchParams(
|
||||
(prev) => {
|
||||
@@ -134,6 +151,14 @@ export default function DashboardPage() {
|
||||
}))
|
||||
}, [planes, hoy])
|
||||
|
||||
if (!resultado) {
|
||||
return errorCarga ? (
|
||||
<div className="border border-red-300 bg-white px-4 py-3 text-sm text-red-700">{errorCarga}</div>
|
||||
) : (
|
||||
<p className="text-sm text-neutral-400">Cargando...</p>
|
||||
)
|
||||
}
|
||||
|
||||
const filtrados = derivados.filter((d) => {
|
||||
if (filtroStatus !== 'TODOS' && d.status !== filtroStatus) return false
|
||||
if (filtroProyecto !== 'TODOS' && d.plan.proyecto !== filtroProyecto) return false
|
||||
@@ -193,6 +218,15 @@ export default function DashboardPage() {
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Aviso discreto cuando los datos no vienen del API */}
|
||||
{resultado.origen === 'local' && (
|
||||
<p className="text-xs text-amber-700">
|
||||
{resultado.planesSoloLocales
|
||||
? 'El API no tiene planes registrados. Los planes guardados en este navegador se conservan solo localmente (no se migraron automáticamente).'
|
||||
: 'Modo offline: sin conexión al API; los cambios se guardan solo en este navegador.'}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* KPIs */}
|
||||
<div className="grid grid-cols-2 gap-6 lg:grid-cols-4">
|
||||
<KpiCard titulo="Planes activos" valor={activos.length} />
|
||||
|
||||
@@ -1,15 +1,31 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import type { PlanPago } from '../types'
|
||||
import { cargarPlanes } from '../services/repository'
|
||||
import { listarPlanes, type ResultadoPlanes } from '../services/repository'
|
||||
import { cobranzaDelDia, round2, statusPago } from '../lib/cobranza'
|
||||
import { formatoFechaCorta, formatoMoneda } from '../lib/formatters'
|
||||
import KpiCard from '../components/KpiCard'
|
||||
|
||||
/** Vista "Cobranza del día": vencidos + por vencer en 7 días, a través de todos los planes activos */
|
||||
export default function HoyPage() {
|
||||
const [planes] = useState<PlanPago[]>(() => cargarPlanes())
|
||||
const [resultado, setResultado] = useState<ResultadoPlanes | null>(null)
|
||||
const [errorCarga, setErrorCarga] = useState<string | null>(null)
|
||||
const hoy = useMemo(() => new Date(), [])
|
||||
|
||||
useEffect(() => {
|
||||
let activo = true
|
||||
listarPlanes()
|
||||
.then((r) => {
|
||||
if (activo) setResultado(r)
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
if (activo) setErrorCarga(e instanceof Error ? e.message : 'Error al cargar los planes')
|
||||
})
|
||||
return () => {
|
||||
activo = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
const planes = useMemo(() => resultado?.planes ?? [], [resultado])
|
||||
const items = useMemo(() => cobranzaDelDia(planes, hoy), [planes, hoy])
|
||||
|
||||
const vencidos = items.filter((i) => i.atraso > 0)
|
||||
@@ -18,6 +34,14 @@ export default function HoyPage() {
|
||||
items.reduce((acc, i) => acc + (i.pago.monto - i.pago.montoPagado), 0),
|
||||
)
|
||||
|
||||
if (!resultado) {
|
||||
return errorCarga ? (
|
||||
<div className="border border-red-300 bg-white px-4 py-3 text-sm text-red-700">{errorCarga}</div>
|
||||
) : (
|
||||
<p className="text-sm text-neutral-400">Cargando...</p>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-16">
|
||||
<div>
|
||||
@@ -25,6 +49,11 @@ export default function HoyPage() {
|
||||
<p className="mt-1 text-sm text-neutral-500">
|
||||
Pagos vencidos y vencimientos de los próximos 7 días, en todos los planes activos.
|
||||
</p>
|
||||
{resultado.origen === 'local' && (
|
||||
<p className="mt-2 text-xs text-amber-700">
|
||||
Modo offline: sin conexión al API; mostrando datos guardados en este navegador.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-6 lg:grid-cols-3">
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useState } from 'react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { z } from 'zod'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { crearPlan } from '../services/repository'
|
||||
import { crearPlanAsync } from '../services/repository'
|
||||
import { generarCalendario } from '../lib/cobranza'
|
||||
import { fechaISOHoy, formatoFechaCorta, formatoMoneda } from '../lib/formatters'
|
||||
|
||||
@@ -42,6 +43,8 @@ const claseLabel = 'block text-xs font-bold uppercase tracking-widest text-neutr
|
||||
|
||||
export default function NuevoPlanPage() {
|
||||
const navigate = useNavigate()
|
||||
const [guardando, setGuardando] = useState(false)
|
||||
const [errorGuardado, setErrorGuardado] = useState<string | null>(null)
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
@@ -71,9 +74,17 @@ export default function NuevoPlanPage() {
|
||||
vistaPrevia = null
|
||||
}
|
||||
|
||||
const guardar = (datos: NuevoPlanForm) => {
|
||||
const plan = crearPlan(datos)
|
||||
void navigate(`/planes/${plan.id}`)
|
||||
const guardar = async (datos: NuevoPlanForm) => {
|
||||
setGuardando(true)
|
||||
setErrorGuardado(null)
|
||||
try {
|
||||
const { plan } = await crearPlanAsync(datos)
|
||||
void navigate(`/planes/${plan.id}`)
|
||||
} catch (e) {
|
||||
setErrorGuardado(e instanceof Error ? e.message : 'Error al guardar el plan')
|
||||
} finally {
|
||||
setGuardando(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -192,7 +203,8 @@ export default function NuevoPlanPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-8 flex justify-end gap-3">
|
||||
<div className="mt-8 flex items-center justify-end gap-3">
|
||||
{errorGuardado && <p className="text-xs text-red-700">{errorGuardado}</p>}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void navigate('/')}
|
||||
@@ -202,9 +214,10 @@ export default function NuevoPlanPage() {
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={guardando}
|
||||
className="bg-accent px-5 py-2.5 text-xs font-bold uppercase tracking-widest text-white transition-colors duration-150 hover:opacity-90 disabled:opacity-40"
|
||||
>
|
||||
Guardar plan
|
||||
{guardando ? 'Guardando…' : 'Guardar plan'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { Fragment, useMemo, useState } from 'react'
|
||||
import { Fragment, useEffect, useMemo, useState } from 'react'
|
||||
import { Link, useNavigate, useParams } from 'react-router-dom'
|
||||
import type { Pago, PlanPago } from '../types'
|
||||
import { agregarNota, cancelarPlan, obtenerPlan, registrarAbonoEnPlan } from '../services/repository'
|
||||
import {
|
||||
agregarNotaAsync,
|
||||
cancelarPlanAsync,
|
||||
obtenerPlanPorId,
|
||||
registrarAbonoAsync,
|
||||
} from '../services/repository'
|
||||
import {
|
||||
avancePlan,
|
||||
diasAtraso,
|
||||
@@ -23,7 +28,10 @@ import ModalAbono from '../components/ModalAbono'
|
||||
export default function PlanDetallePage() {
|
||||
const { id } = useParams<{ id: string }>()
|
||||
const navigate = useNavigate()
|
||||
const [plan, setPlan] = useState<PlanPago | undefined>(() => (id ? obtenerPlan(id) : undefined))
|
||||
const [plan, setPlan] = useState<PlanPago | undefined>(undefined)
|
||||
const [cargando, setCargando] = useState(true)
|
||||
const [offline, setOffline] = useState(false)
|
||||
const [errorCarga, setErrorCarga] = useState<string | null>(null)
|
||||
const [pagoAbono, setPagoAbono] = useState<Pago | null>(null)
|
||||
const [expandidos, setExpandidos] = useState<Set<string>>(new Set())
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
@@ -31,6 +39,37 @@ export default function PlanDetallePage() {
|
||||
const [errorNota, setErrorNota] = useState<string | null>(null)
|
||||
const hoy = useMemo(() => new Date(), [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) {
|
||||
setCargando(false)
|
||||
return
|
||||
}
|
||||
let activo = true
|
||||
obtenerPlanPorId(id)
|
||||
.then((r) => {
|
||||
if (!activo) return
|
||||
setPlan(r.plan)
|
||||
setOffline(r.origen === 'local')
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
if (activo) setErrorCarga(e instanceof Error ? e.message : 'Error al cargar el plan')
|
||||
})
|
||||
.finally(() => {
|
||||
if (activo) setCargando(false)
|
||||
})
|
||||
return () => {
|
||||
activo = false
|
||||
}
|
||||
}, [id])
|
||||
|
||||
if (cargando) {
|
||||
return <p className="text-sm text-neutral-400">Cargando...</p>
|
||||
}
|
||||
|
||||
if (errorCarga) {
|
||||
return <div className="border border-red-300 bg-white px-4 py-3 text-sm text-red-700">{errorCarga}</div>
|
||||
}
|
||||
|
||||
if (!plan) {
|
||||
return (
|
||||
<div className="border border-neutral-200 bg-white p-16 text-center">
|
||||
@@ -57,11 +96,12 @@ export default function PlanDetallePage() {
|
||||
})
|
||||
}
|
||||
|
||||
const guardarAbono = (datos: { monto: number; fechaPago: string; referencia: string }) => {
|
||||
const guardarAbono = async (datos: { monto: number; fechaPago: string; referencia: string }) => {
|
||||
if (!pagoAbono) return
|
||||
try {
|
||||
const actualizado = registrarAbonoEnPlan(plan.id, datos, pagoAbono.id)
|
||||
setPlan(actualizado)
|
||||
const r = await registrarAbonoAsync(plan.id, datos, pagoAbono.id)
|
||||
setPlan(r.plan)
|
||||
setOffline(r.origen === 'local')
|
||||
setPagoAbono(null)
|
||||
setError(null)
|
||||
} catch (e) {
|
||||
@@ -69,14 +109,23 @@ export default function PlanDetallePage() {
|
||||
}
|
||||
}
|
||||
|
||||
const cancelar = () => {
|
||||
const cancelar = async () => {
|
||||
if (!window.confirm('¿Cancelar este plan? El saldo pendiente dejará de cobrarse.')) return
|
||||
setPlan(cancelarPlan(plan.id))
|
||||
try {
|
||||
const r = await cancelarPlanAsync(plan.id)
|
||||
setPlan(r.plan)
|
||||
setOffline(r.origen === 'local')
|
||||
setError(null)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Error al cancelar el plan')
|
||||
}
|
||||
}
|
||||
|
||||
const guardarNota = () => {
|
||||
const guardarNota = async () => {
|
||||
try {
|
||||
setPlan(agregarNota(plan.id, notaTexto))
|
||||
const r = await agregarNotaAsync(plan, notaTexto)
|
||||
setPlan(r.plan)
|
||||
setOffline(r.origen === 'local')
|
||||
setNotaTexto('')
|
||||
setErrorNota(null)
|
||||
} catch (e) {
|
||||
@@ -120,6 +169,12 @@ export default function PlanDetallePage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{offline && (
|
||||
<p className="text-xs text-amber-700">
|
||||
Modo offline: sin conexión al API; los cambios se guardan solo en este navegador.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="border border-red-300 bg-white px-4 py-3 text-sm text-red-700">{error}</div>
|
||||
)}
|
||||
@@ -277,7 +332,7 @@ export default function PlanDetallePage() {
|
||||
className="mt-8"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
guardarNota()
|
||||
void guardarNota()
|
||||
}}
|
||||
>
|
||||
<label
|
||||
|
||||
@@ -1,11 +1,44 @@
|
||||
import type { PlanPago } from '../types'
|
||||
import { cargarPlanes } from './repository'
|
||||
// Cliente HTTP del API Gateway de FRASA (Express + Prisma)
|
||||
const API_URL = import.meta.env.VITE_API_URL ?? 'http://192.168.10.123:3000/api'
|
||||
// TODO produccion: '/api' same-origin detras de NGINX
|
||||
|
||||
/**
|
||||
* Devuelve los planes de pago registrados.
|
||||
* TODO: reemplazar por fetch('/api/planes') cuando el API Gateway esté disponible
|
||||
* TODO: cron de vencidos y envío WhatsApp van en el API Gateway (fase posterior)
|
||||
* Error HTTP del API (respuesta con status >= 400, p. ej. validación o 404).
|
||||
* NO amerita fallback offline: el servidor respondió y su respuesta manda.
|
||||
*/
|
||||
export async function getPlanes(): Promise<PlanPago[]> {
|
||||
return cargarPlanes()
|
||||
export class ErrorApi extends Error {
|
||||
constructor(
|
||||
public readonly status: number,
|
||||
message: string,
|
||||
) {
|
||||
super(message)
|
||||
this.name = 'ErrorApi'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* fetch solo rechaza (TypeError) cuando falla la red; una respuesta HTTP de
|
||||
* error resuelve normalmente. El fallback offline solo aplica al primer caso.
|
||||
*/
|
||||
export function esErrorDeRed(err: unknown): boolean {
|
||||
return err instanceof TypeError
|
||||
}
|
||||
|
||||
/** Llama al API Gateway; lanza ErrorApi con el mensaje del servidor si el status no es 2xx */
|
||||
export async function request<T>(ruta: string, init?: RequestInit): Promise<T> {
|
||||
const res = await fetch(`${API_URL}${ruta}`, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
...init,
|
||||
})
|
||||
if (!res.ok) {
|
||||
let mensaje = `Error ${res.status} al llamar ${ruta}`
|
||||
try {
|
||||
const cuerpo = (await res.json()) as { error?: string }
|
||||
if (typeof cuerpo.error === 'string') mensaje = cuerpo.error
|
||||
} catch {
|
||||
// cuerpo no JSON: se queda el mensaje genérico
|
||||
}
|
||||
throw new ErrorApi(res.status, mensaje)
|
||||
}
|
||||
return (await res.json()) as T
|
||||
}
|
||||
|
||||
337
apps/cobranza/src/services/repository.api.test.ts
Normal file
337
apps/cobranza/src/services/repository.api.test.ts
Normal file
@@ -0,0 +1,337 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
CLAVE_STORAGE,
|
||||
agregarNotaAsync,
|
||||
cancelarPlanAsync,
|
||||
crearPlan,
|
||||
crearPlanAsync,
|
||||
listarPlanes,
|
||||
obtenerPlanPorId,
|
||||
registrarAbonoAsync,
|
||||
type NuevoPlanInput,
|
||||
type PlanApi,
|
||||
type StorageLike,
|
||||
} from './repository'
|
||||
import { ErrorApi } from './api'
|
||||
import type { PlanPago } from '../types'
|
||||
|
||||
/** Stub sencillo de localStorage para correr en entorno node */
|
||||
function crearStorageStub(): StorageLike {
|
||||
const mapa = new Map<string, string>()
|
||||
return {
|
||||
getItem: (k: string) => mapa.get(k) ?? null,
|
||||
setItem: (k: string, v: string) => {
|
||||
mapa.set(k, v)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function respuestaJson(valor: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(valor), {
|
||||
status,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
|
||||
/** fetch mockeado con la firma tipada (url, init) para inspeccionar las llamadas */
|
||||
function fetchQue(impl: () => Promise<Response>) {
|
||||
return vi.fn<(url: string, init?: RequestInit) => Promise<Response>>(impl)
|
||||
}
|
||||
|
||||
/** fetch que rechaza como cuando no hay red / el API está caído */
|
||||
function fetchSinRed() {
|
||||
return fetchQue(async () => {
|
||||
throw new TypeError('fetch failed')
|
||||
})
|
||||
}
|
||||
|
||||
const INPUT_PLAN: NuevoPlanInput = {
|
||||
clienteNombre: 'Cliente Prueba',
|
||||
unidadCodigo: 'U-100',
|
||||
proyecto: 'SENDA',
|
||||
ventaRef: 'VTA-1',
|
||||
fechaInicio: '2026-09-01',
|
||||
engancheTotal: 60000,
|
||||
numPagos: 6,
|
||||
}
|
||||
|
||||
/** Plan con el shape del API Gateway (nulls de Prisma); los calculados extra se agregan inline */
|
||||
function planApi(overrides: Partial<PlanApi> = {}): PlanApi {
|
||||
return {
|
||||
id: 'api-1',
|
||||
ventaRef: null,
|
||||
clienteNombre: 'Cliente API',
|
||||
clienteTelefono: null,
|
||||
unidadCodigo: 'U-100',
|
||||
proyecto: 'SENDA',
|
||||
fechaInicio: '2026-09-01',
|
||||
engancheTotal: 60000,
|
||||
numPagos: 6,
|
||||
mensualidad: 10000,
|
||||
tasaMoraMensual: null,
|
||||
status: 'ACTIVO',
|
||||
pagos: [],
|
||||
notas: [],
|
||||
createdAt: '2026-08-01T00:00:00.000Z',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function planLocal(overrides: Partial<PlanPago> = {}): PlanPago {
|
||||
return {
|
||||
id: 'local-1',
|
||||
ventaRef: '',
|
||||
clienteNombre: 'Cliente Local',
|
||||
unidadCodigo: 'U-1',
|
||||
proyecto: 'SENDA',
|
||||
fechaInicio: '2026-09-01',
|
||||
engancheTotal: 10000,
|
||||
numPagos: 2,
|
||||
mensualidad: 5000,
|
||||
status: 'ACTIVO',
|
||||
pagos: [],
|
||||
notas: [],
|
||||
createdAt: '2026-08-01T00:00:00.000Z',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('repository async (API primero, fallback localStorage)', () => {
|
||||
it('listarPlanes devuelve los planes del API normalizados y sin los calculados del servidor', async () => {
|
||||
const fetchMock = fetchQue(async () =>
|
||||
// El API manda campos calculados (saldo, avance…); el frontend los deriva por su cuenta
|
||||
respuestaJson([{ ...planApi(), saldo: 60000, avance: 0 }]),
|
||||
)
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
const r = await listarPlanes(crearStorageStub())
|
||||
|
||||
expect(r.origen).toBe('api')
|
||||
expect(r.planesSoloLocales).toBe(false)
|
||||
expect(r.planes).toHaveLength(1)
|
||||
const plan = r.planes[0]
|
||||
expect(plan.ventaRef).toBe('') // null → ''
|
||||
expect(plan.clienteTelefono).toBeUndefined() // null → undefined
|
||||
expect(plan.tasaMoraMensual).toBeUndefined()
|
||||
expect(plan).not.toHaveProperty('saldo')
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'http://192.168.10.123:3000/api/planes',
|
||||
expect.objectContaining({ headers: { 'Content-Type': 'application/json' } }),
|
||||
)
|
||||
})
|
||||
|
||||
it('listarPlanes cae a localStorage (con seed) cuando fetch rechaza por red', async () => {
|
||||
vi.stubGlobal('fetch', fetchSinRed())
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const storage = crearStorageStub()
|
||||
|
||||
const r = await listarPlanes(storage)
|
||||
|
||||
expect(r.origen).toBe('local')
|
||||
expect(r.planesSoloLocales).toBe(false)
|
||||
expect(r.planes).toHaveLength(4) // datos de ejemplo sembrados
|
||||
expect(storage.getItem(CLAVE_STORAGE)).not.toBeNull()
|
||||
expect(warn).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('listarPlanes NO cae a localStorage ante un error HTTP del API', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
fetchQue(async () => respuestaJson({ error: 'Error interno del servidor' }, 500)),
|
||||
)
|
||||
|
||||
await expect(listarPlanes(crearStorageStub())).rejects.toBeInstanceOf(ErrorApi)
|
||||
})
|
||||
|
||||
it('conserva los planes locales cuando el API responde vacío (sin migrar automáticamente)', async () => {
|
||||
vi.stubGlobal('fetch', fetchQue(async () => respuestaJson([])))
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const storage = crearStorageStub()
|
||||
storage.setItem(CLAVE_STORAGE, JSON.stringify([planLocal()]))
|
||||
|
||||
const r = await listarPlanes(storage)
|
||||
|
||||
expect(r.origen).toBe('local')
|
||||
expect(r.planesSoloLocales).toBe(true)
|
||||
expect(r.planes.map((p) => p.id)).toEqual(['local-1'])
|
||||
expect(warn).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('API vacío sin planes locales → lista vacía con origen api', async () => {
|
||||
vi.stubGlobal('fetch', fetchQue(async () => respuestaJson([])))
|
||||
|
||||
const r = await listarPlanes(crearStorageStub())
|
||||
|
||||
expect(r).toEqual({ planes: [], origen: 'api', planesSoloLocales: false })
|
||||
})
|
||||
|
||||
it('obtenerPlanPorId devuelve undefined ante un 404 del API (sin fallback)', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
fetchQue(async () => respuestaJson({ error: 'Plan de pago no encontrado' }, 404)),
|
||||
)
|
||||
|
||||
const r = await obtenerPlanPorId('no-existe', crearStorageStub())
|
||||
|
||||
expect(r.plan).toBeUndefined()
|
||||
expect(r.origen).toBe('api')
|
||||
})
|
||||
|
||||
it('obtenerPlanPorId cae a localStorage cuando falla la red', async () => {
|
||||
vi.stubGlobal('fetch', fetchSinRed())
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const storage = crearStorageStub()
|
||||
storage.setItem(CLAVE_STORAGE, JSON.stringify([planLocal()]))
|
||||
|
||||
const r = await obtenerPlanPorId('local-1', storage)
|
||||
|
||||
expect(r.origen).toBe('local')
|
||||
expect(r.plan?.clienteNombre).toBe('Cliente Local')
|
||||
})
|
||||
|
||||
it('crearPlanAsync hace POST al API y no escribe en localStorage', async () => {
|
||||
const fetchMock = fetchQue(async () => respuestaJson(planApi({ id: 'api-nuevo' }), 201))
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
const storage = crearStorageStub()
|
||||
|
||||
const r = await crearPlanAsync(INPUT_PLAN, storage)
|
||||
|
||||
expect(r.origen).toBe('api')
|
||||
expect(r.plan.id).toBe('api-nuevo')
|
||||
expect(storage.getItem(CLAVE_STORAGE)).toBeNull()
|
||||
const [url, init] = fetchMock.mock.calls[0]
|
||||
expect(url).toBe('http://192.168.10.123:3000/api/planes')
|
||||
expect(init?.method).toBe('POST')
|
||||
const body = JSON.parse(String(init?.body)) as Record<string, unknown>
|
||||
expect(body.clienteNombre).toBe('Cliente Prueba')
|
||||
expect(body.engancheTotal).toBe(60000)
|
||||
expect(body.numPagos).toBe(6)
|
||||
})
|
||||
|
||||
it('crearPlanAsync guarda el plan en localStorage cuando falla la red', async () => {
|
||||
vi.stubGlobal('fetch', fetchSinRed())
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const storage = crearStorageStub()
|
||||
|
||||
const r = await crearPlanAsync(INPUT_PLAN, storage)
|
||||
|
||||
expect(r.origen).toBe('local')
|
||||
expect(r.plan.mensualidad).toBe(10000)
|
||||
const guardados = JSON.parse(storage.getItem(CLAVE_STORAGE) ?? '[]') as PlanPago[]
|
||||
expect(guardados).toHaveLength(5) // 4 del seed + 1 nuevo
|
||||
})
|
||||
|
||||
it('registrarAbonoAsync envía pagoId/fechaPago/monto/referencia al API', async () => {
|
||||
const fetchMock = fetchQue(async () => respuestaJson(planApi(), 201))
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
const r = await registrarAbonoAsync(
|
||||
'api-1',
|
||||
{ fechaPago: '2026-09-02', monto: 10000, referencia: 'SPEI-1' },
|
||||
'pago-1',
|
||||
crearStorageStub(),
|
||||
)
|
||||
|
||||
expect(r.origen).toBe('api')
|
||||
const [url, init] = fetchMock.mock.calls[0]
|
||||
expect(url).toBe('http://192.168.10.123:3000/api/planes/api-1/abonos')
|
||||
const body = JSON.parse(String(init?.body)) as Record<string, unknown>
|
||||
expect(body).toEqual({ pagoId: 'pago-1', fechaPago: '2026-09-02', monto: 10000, referencia: 'SPEI-1' })
|
||||
})
|
||||
|
||||
it('registrarAbonoAsync propaga el ErrorApi de validación sin caer a local', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
fetchQue(async () => respuestaJson({ error: 'El abono excede el restante' }, 400)),
|
||||
)
|
||||
const storage = crearStorageStub()
|
||||
|
||||
await expect(
|
||||
registrarAbonoAsync('api-1', { fechaPago: '2026-09-02', monto: 999999, referencia: 'X1' }, 'pago-1', storage),
|
||||
).rejects.toBeInstanceOf(ErrorApi)
|
||||
expect(storage.getItem(CLAVE_STORAGE)).toBeNull()
|
||||
})
|
||||
|
||||
it('registrarAbonoAsync aplica el abono en localStorage cuando falla la red', async () => {
|
||||
vi.stubGlobal('fetch', fetchSinRed())
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const storage = crearStorageStub()
|
||||
const plan = crearPlan({ ...INPUT_PLAN, engancheTotal: 20000, numPagos: 2 }, storage)
|
||||
|
||||
const r = await registrarAbonoAsync(
|
||||
plan.id,
|
||||
{ fechaPago: '2026-09-01', monto: 10000, referencia: 'A1' },
|
||||
plan.pagos[0].id,
|
||||
storage,
|
||||
)
|
||||
|
||||
expect(r.origen).toBe('local')
|
||||
expect(r.plan.pagos[0].montoPagado).toBe(10000)
|
||||
})
|
||||
|
||||
it('agregarNotaAsync anexa al plan la nota devuelta por el API', async () => {
|
||||
const fetchMock = fetchQue(async () =>
|
||||
respuestaJson({ id: 'nota-1', planId: 'api-1', fecha: '2026-08-21T12:00:00.000Z', texto: 'Se habló con el cliente' }, 201),
|
||||
)
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
const plan = { ...planApi(), ventaRef: '', clienteTelefono: undefined, tasaMoraMensual: undefined }
|
||||
|
||||
const r = await agregarNotaAsync(plan, 'Se habló con el cliente', crearStorageStub())
|
||||
|
||||
expect(r.origen).toBe('api')
|
||||
expect(r.plan.notas).toHaveLength(1)
|
||||
expect(r.plan.notas[0]).toEqual({
|
||||
id: 'nota-1',
|
||||
fecha: '2026-08-21T12:00:00.000Z',
|
||||
texto: 'Se habló con el cliente',
|
||||
})
|
||||
const [url] = fetchMock.mock.calls[0]
|
||||
expect(url).toBe('http://192.168.10.123:3000/api/planes/api-1/notas')
|
||||
})
|
||||
|
||||
it('agregarNotaAsync cae a localStorage cuando falla la red (conserva la validación local)', async () => {
|
||||
vi.stubGlobal('fetch', fetchSinRed())
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const storage = crearStorageStub()
|
||||
const plan = planLocal()
|
||||
storage.setItem(CLAVE_STORAGE, JSON.stringify([plan]))
|
||||
|
||||
const r = await agregarNotaAsync(plan, 'Se habló con el cliente', storage)
|
||||
expect(r.origen).toBe('local')
|
||||
expect(r.plan.notas).toHaveLength(1)
|
||||
|
||||
await expect(agregarNotaAsync(plan, 'ok', storage)).rejects.toThrow(/al menos 3/)
|
||||
})
|
||||
|
||||
it('cancelarPlanAsync cancela vía API', async () => {
|
||||
const fetchMock = fetchQue(async () => respuestaJson(planApi({ status: 'CANCELADO' })))
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
const r = await cancelarPlanAsync('api-1', crearStorageStub())
|
||||
|
||||
expect(r.origen).toBe('api')
|
||||
expect(r.plan.status).toBe('CANCELADO')
|
||||
const [url, init] = fetchMock.mock.calls[0]
|
||||
expect(url).toBe('http://192.168.10.123:3000/api/planes/api-1/cancelar')
|
||||
expect(init?.method).toBe('POST')
|
||||
})
|
||||
|
||||
it('cancelarPlanAsync marca CANCELADO en localStorage cuando falla la red', async () => {
|
||||
vi.stubGlobal('fetch', fetchSinRed())
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const storage = crearStorageStub()
|
||||
storage.setItem(CLAVE_STORAGE, JSON.stringify([planLocal()]))
|
||||
|
||||
const r = await cancelarPlanAsync('local-1', storage)
|
||||
|
||||
expect(r.origen).toBe('local')
|
||||
expect(r.plan.status).toBe('CANCELADO')
|
||||
const guardados = JSON.parse(storage.getItem(CLAVE_STORAGE) ?? '[]') as PlanPago[]
|
||||
expect(guardados[0].status).toBe('CANCELADO')
|
||||
})
|
||||
})
|
||||
@@ -1,12 +1,13 @@
|
||||
import type { PlanPago } from '../types'
|
||||
import type { NotaPlan, PlanPago } from '../types'
|
||||
import { generarSeed } from '../data/seed'
|
||||
import { generarCalendario, registrarAbono, statusPlan, agregarNotaAPlan, type AbonoInput } from '../lib/cobranza'
|
||||
import { uuid } from '../lib/uuid'
|
||||
import { ErrorApi, esErrorDeRed, request } from './api'
|
||||
|
||||
export const CLAVE_STORAGE = 'frasa_cobranza_v1'
|
||||
|
||||
// Inyectable para pruebas (stub en vitest, donde no hay localStorage)
|
||||
type StorageLike = Pick<Storage, 'getItem' | 'setItem'>
|
||||
export type StorageLike = Pick<Storage, 'getItem' | 'setItem'>
|
||||
|
||||
function storageDefault(): StorageLike {
|
||||
return globalThis.localStorage
|
||||
@@ -15,7 +16,7 @@ function storageDefault(): StorageLike {
|
||||
/**
|
||||
* Carga los planes desde localStorage; si está vacío siembra los datos de ejemplo.
|
||||
* Migra planes guardados con versiones anteriores del modelo (sin `notas`).
|
||||
* TODO: reemplazar por GET /api/planes cuando el API Gateway esté disponible.
|
||||
* Es el respaldo offline de `listarPlanes` cuando el API no es alcanzable.
|
||||
*/
|
||||
export function cargarPlanes(storage: StorageLike = storageDefault()): PlanPago[] {
|
||||
const crudo = storage.getItem(CLAVE_STORAGE)
|
||||
@@ -113,3 +114,175 @@ export function agregarNota(
|
||||
if (!plan) throw new Error('Plan no encontrado')
|
||||
return actualizarPlan(agregarNotaAPlan(plan, texto), storage)
|
||||
}
|
||||
|
||||
// ---------- Capa async: API primero, localStorage como respaldo offline ----------
|
||||
|
||||
/** De dónde salieron los datos de una operación */
|
||||
export type OrigenDatos = 'api' | 'local'
|
||||
|
||||
export interface ResultadoPlanes {
|
||||
planes: PlanPago[]
|
||||
origen: OrigenDatos
|
||||
/** true si el API respondió vacío pero hay planes locales: se conservan solo en este navegador */
|
||||
planesSoloLocales: boolean
|
||||
}
|
||||
|
||||
export interface ResultadoPlan {
|
||||
plan: PlanPago
|
||||
origen: OrigenDatos
|
||||
}
|
||||
|
||||
/**
|
||||
* Shape del plan tal como lo devuelve el API Gateway: los opcionales llegan como
|
||||
* null (Prisma) y trae campos calculados extra (saldo, avance…) que aquí se
|
||||
* ignoran porque el frontend los deriva con las funciones puras de lib/cobranza.
|
||||
*/
|
||||
export type PlanApi = Omit<PlanPago, 'ventaRef' | 'clienteTelefono' | 'tasaMoraMensual'> & {
|
||||
ventaRef: string | null
|
||||
clienteTelefono: string | null
|
||||
tasaMoraMensual: number | null
|
||||
}
|
||||
|
||||
/** Normaliza el plan del API al modelo del frontend (null → '' / undefined) */
|
||||
function desdeApi(p: PlanApi): PlanPago {
|
||||
return {
|
||||
id: p.id,
|
||||
ventaRef: p.ventaRef ?? '',
|
||||
clienteNombre: p.clienteNombre,
|
||||
clienteTelefono: p.clienteTelefono ?? undefined,
|
||||
unidadCodigo: p.unidadCodigo,
|
||||
proyecto: p.proyecto,
|
||||
fechaInicio: p.fechaInicio,
|
||||
engancheTotal: p.engancheTotal,
|
||||
numPagos: p.numPagos,
|
||||
mensualidad: p.mensualidad,
|
||||
tasaMoraMensual: p.tasaMoraMensual ?? undefined,
|
||||
status: p.status,
|
||||
pagos: p.pagos,
|
||||
notas: p.notas ?? [],
|
||||
createdAt: p.createdAt,
|
||||
}
|
||||
}
|
||||
|
||||
/** Lee los planes locales sin sembrar, para detectar datos que solo existen en este navegador */
|
||||
function planesLocalesSinSembrar(storage: StorageLike): PlanPago[] {
|
||||
const crudo = storage.getItem(CLAVE_STORAGE)
|
||||
if (!crudo) return []
|
||||
const planes = JSON.parse(crudo) as PlanPago[]
|
||||
return planes.map((p) => (p.notas ? p : { ...p, notas: [] }))
|
||||
}
|
||||
|
||||
/**
|
||||
* 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`.
|
||||
*/
|
||||
export async function listarPlanes(storage: StorageLike = storageDefault()): Promise<ResultadoPlanes> {
|
||||
try {
|
||||
const planes = (await request<PlanApi[]>('/planes')).map(desdeApi)
|
||||
if (planes.length === 0) {
|
||||
const locales = planesLocalesSinSembrar(storage)
|
||||
if (locales.length > 0) {
|
||||
console.warn('[cobranza] El API no tiene planes; se conservan los planes locales sin migrarlos')
|
||||
return { planes: locales, origen: 'local', planesSoloLocales: true }
|
||||
}
|
||||
}
|
||||
return { planes, origen: 'api', planesSoloLocales: false }
|
||||
} catch (err) {
|
||||
if (!esErrorDeRed(err)) throw err
|
||||
console.warn('[cobranza] API no disponible; usando localStorage (modo offline):', err)
|
||||
return { planes: cargarPlanes(storage), origen: 'local', planesSoloLocales: false }
|
||||
}
|
||||
}
|
||||
|
||||
/** GET /planes/:id. 404 del API → plan undefined (respuesta definitiva, sin fallback) */
|
||||
export async function obtenerPlanPorId(
|
||||
id: string,
|
||||
storage: StorageLike = storageDefault(),
|
||||
): Promise<{ plan: PlanPago | undefined; origen: OrigenDatos }> {
|
||||
try {
|
||||
const plan = await request<PlanApi>(`/planes/${encodeURIComponent(id)}`)
|
||||
return { plan: desdeApi(plan), origen: 'api' }
|
||||
} catch (err) {
|
||||
if (err instanceof ErrorApi && err.status === 404) return { plan: undefined, origen: 'api' }
|
||||
if (!esErrorDeRed(err)) throw err
|
||||
console.warn('[cobranza] API no disponible; leyendo el plan desde localStorage:', err)
|
||||
return { plan: obtenerPlan(id, storage), origen: 'local' }
|
||||
}
|
||||
}
|
||||
|
||||
/** POST /planes. Errores HTTP (validación) se propagan; solo la red cae a local */
|
||||
export async function crearPlanAsync(
|
||||
input: NuevoPlanInput,
|
||||
storage: StorageLike = storageDefault(),
|
||||
): Promise<ResultadoPlan> {
|
||||
try {
|
||||
const plan = await request<PlanApi>('/planes', { method: 'POST', body: JSON.stringify(input) })
|
||||
return { plan: desdeApi(plan), origen: 'api' }
|
||||
} catch (err) {
|
||||
if (!esErrorDeRed(err)) throw err
|
||||
console.warn('[cobranza] API no disponible; el plan se guardó solo en localStorage:', err)
|
||||
return { plan: crearPlan(input, storage), origen: 'local' }
|
||||
}
|
||||
}
|
||||
|
||||
/** POST /planes/:id/abonos (estricto al centavo en el servidor) */
|
||||
export async function registrarAbonoAsync(
|
||||
planId: string,
|
||||
abono: Omit<AbonoInput, 'pagoId'>,
|
||||
pagoId: string,
|
||||
storage: StorageLike = storageDefault(),
|
||||
): Promise<ResultadoPlan> {
|
||||
try {
|
||||
const plan = await request<PlanApi>(`/planes/${encodeURIComponent(planId)}/abonos`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ ...abono, pagoId }),
|
||||
})
|
||||
return { plan: desdeApi(plan), origen: 'api' }
|
||||
} catch (err) {
|
||||
if (!esErrorDeRed(err)) throw err
|
||||
console.warn('[cobranza] API no disponible; el abono se registró solo en localStorage:', err)
|
||||
return { plan: registrarAbonoEnPlan(planId, abono, pagoId, storage), origen: 'local' }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /planes/:id/notas. El API devuelve solo la nota creada; se anexa al plan
|
||||
* en memoria (las notas no afectan status, saldo ni calendario).
|
||||
*/
|
||||
export async function agregarNotaAsync(
|
||||
plan: PlanPago,
|
||||
texto: string,
|
||||
storage: StorageLike = storageDefault(),
|
||||
): Promise<ResultadoPlan> {
|
||||
try {
|
||||
const nota = await request<NotaPlan>(`/planes/${encodeURIComponent(plan.id)}/notas`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ texto }),
|
||||
})
|
||||
const actualizado: PlanPago = {
|
||||
...plan,
|
||||
notas: [...plan.notas, { id: nota.id, fecha: nota.fecha, texto: nota.texto }],
|
||||
}
|
||||
return { plan: actualizado, origen: 'api' }
|
||||
} catch (err) {
|
||||
if (!esErrorDeRed(err)) throw err
|
||||
console.warn('[cobranza] API no disponible; la nota se guardó solo en localStorage:', err)
|
||||
return { plan: agregarNota(plan.id, texto, storage), origen: 'local' }
|
||||
}
|
||||
}
|
||||
|
||||
/** POST /planes/:id/cancelar */
|
||||
export async function cancelarPlanAsync(
|
||||
id: string,
|
||||
storage: StorageLike = storageDefault(),
|
||||
): Promise<ResultadoPlan> {
|
||||
try {
|
||||
const plan = await request<PlanApi>(`/planes/${encodeURIComponent(id)}/cancelar`, { method: 'POST' })
|
||||
return { plan: desdeApi(plan), origen: 'api' }
|
||||
} catch (err) {
|
||||
if (!esErrorDeRed(err)) throw err
|
||||
console.warn('[cobranza] API no disponible; el plan se canceló solo en localStorage:', err)
|
||||
return { plan: cancelarPlan(id, storage), origen: 'local' }
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user