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:
2026-08-21 23:39:17 +00:00
parent ecd4edac02
commit 0546f35d2a
48 changed files with 7129 additions and 66 deletions

View File

@@ -10,19 +10,25 @@ import DocumentosForm from '../forms/DocumentosForm'
import FirmasForm from '../forms/FirmasForm'
import { useAltaCliente, PASOS } from '../hooks/useAltaCliente'
import { crearAltaCliente, subirDocumentos } from '../services/api'
import { enviarAMonday } from '../services/monday'
export default function AltaClientePage() {
const { paso, siguiente, anterior, irAPaso, datos, alta, actualizar, agregarDocumento, quitarDocumento } =
useAltaCliente()
const [registro, setRegistro] = useState<string>('')
const [sinConexion, setSinConexion] = useState(false)
// Registra el alta (stub API + Monday) al llegar al paso de firmas
// Registra el alta en el API al llegar al paso de firmas; si el API no
// responde, el flujo continúa solo local (los PDFs se generan igual)
const registrarAlta = async () => {
const { id } = await crearAltaCliente(alta)
await subirDocumentos(id, alta)
await enviarAMonday(alta)
setRegistro(id)
const creada = await crearAltaCliente(alta)
if (creada) {
await subirDocumentos(creada.id, alta)
setRegistro(creada.id)
setSinConexion(false)
} else {
setRegistro('')
setSinConexion(true)
}
}
return (
@@ -117,7 +123,15 @@ export default function AltaClientePage() {
<>
{registro && (
<p className="mb-8 border border-accent bg-white px-4 py-3 text-xs font-bold uppercase tracking-widest text-accent">
Alta registrada (stub API): {registro}
Alta registrada en el servidor: {registro}
</p>
)}
{sinConexion && (
<p
role="alert"
className="mb-8 border border-amber-300 bg-amber-50 px-4 py-3 text-xs font-bold uppercase tracking-widest text-amber-700"
>
Sin conexión al servidor — el expediente se generó solo localmente
</p>
)}
<FirmasForm

View File

@@ -0,0 +1,133 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import type { AltaCliente } from '../types'
import { fixtureAlta } from '../lib/fixture'
import { crearAltaCliente, subirDocumentos } from './api'
type FetchFn = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>
const docPrueba = (): AltaCliente['documentos'] => [
{
slot: 'INE',
nombre: 'ine.pdf',
tamano: 4,
tipoMime: 'application/pdf',
archivo: new File(['pdf!'], 'ine.pdf', { type: 'application/pdf' }),
},
{
slot: 'CURP',
nombre: 'curp.png',
tamano: 4,
tipoMime: 'image/png',
archivo: new File(['png!'], 'curp.png', { type: 'image/png' }),
},
]
const altaConDocumentos = (): AltaCliente => ({ ...fixtureAlta, documentos: docPrueba() })
describe('services/api', () => {
let fetchMock: ReturnType<typeof vi.fn<FetchFn>>
let warnSpy: ReturnType<typeof vi.spyOn>
beforeEach(() => {
fetchMock = vi.fn<FetchFn>()
vi.stubGlobal('fetch', fetchMock)
warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
})
afterEach(() => {
vi.unstubAllGlobals()
warnSpy.mockRestore()
})
describe('crearAltaCliente', () => {
it('hace POST JSON a /clientes y devuelve el id del servidor', async () => {
fetchMock.mockResolvedValue(
new Response(JSON.stringify({ id: 'srv-123' }), {
status: 201,
headers: { 'Content-Type': 'application/json' },
}),
)
const alta = altaConDocumentos()
const resultado = await crearAltaCliente(alta)
expect(resultado).toEqual({ id: 'srv-123' })
expect(fetchMock).toHaveBeenCalledTimes(1)
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]
expect(url).toBe('http://192.168.10.123:3000/api/clientes')
expect(init.method).toBe('POST')
expect(init.headers).toEqual({ 'Content-Type': 'application/json' })
// Los File no viajan en el JSON: solo metadatos; la firma va como string
const cuerpo = JSON.parse(String(init.body)) as Record<string, unknown>
const documentos = cuerpo['documentos'] as Record<string, unknown>[]
expect(documentos).toEqual([
{ tipo: 'INE', nombre: 'ine.pdf', tamano: 4, mime: 'application/pdf' },
{ tipo: 'CURP', nombre: 'curp.png', tamano: 4, mime: 'image/png' },
])
expect(JSON.stringify(cuerpo)).not.toContain('archivo')
expect((cuerpo['firma'] as Record<string, unknown>)['firmaDataUrl']).toBe(
fixtureAlta.firma.firmaDataUrl,
)
})
it('ante un fallo de red devuelve null y avisa con console.warn', async () => {
fetchMock.mockRejectedValue(new TypeError('fetch failed'))
const resultado = await crearAltaCliente(altaConDocumentos())
expect(resultado).toBeNull()
expect(warnSpy).toHaveBeenCalled()
})
it('ante una respuesta HTTP de error devuelve null y avisa', async () => {
fetchMock.mockResolvedValue(new Response('error', { status: 500 }))
const resultado = await crearAltaCliente(altaConDocumentos())
expect(resultado).toBeNull()
expect(warnSpy).toHaveBeenCalled()
})
})
describe('subirDocumentos', () => {
it('arma el multipart correcto: una petición por documento con tipo y archivos', async () => {
fetchMock.mockResolvedValue(new Response('[]', { status: 201 }))
await subirDocumentos('srv-123', altaConDocumentos())
expect(fetchMock).toHaveBeenCalledTimes(2)
const [url1, init1] = fetchMock.mock.calls[0] as [string, RequestInit]
expect(url1).toBe('http://192.168.10.123:3000/api/clientes/srv-123/documentos')
expect(init1.method).toBe('POST')
// Sin Content-Type manual: el navegador pone el boundary del multipart
expect(init1.headers).toBeUndefined()
const form1 = init1.body as FormData
expect(form1).toBeInstanceOf(FormData)
expect(form1.get('tipo')).toBe('INE')
const archivo1 = form1.get('archivos')
expect(archivo1).toBeInstanceOf(File)
expect((archivo1 as File).name).toBe('ine.pdf')
expect((archivo1 as File).type).toBe('application/pdf')
const [, init2] = fetchMock.mock.calls[1] as [string, RequestInit]
const form2 = init2.body as FormData
expect(form2.get('tipo')).toBe('CURP')
expect((form2.get('archivos') as File).name).toBe('curp.png')
})
it('no hace ninguna petición si no hay documentos', async () => {
await subirDocumentos('srv-123', fixtureAlta)
expect(fetchMock).not.toHaveBeenCalled()
})
it('ante un fallo de red avisa y continúa sin lanzar', async () => {
fetchMock.mockRejectedValue(new TypeError('fetch failed'))
await expect(subirDocumentos('srv-123', altaConDocumentos())).resolves.toBeUndefined()
expect(warnSpy).toHaveBeenCalledTimes(2)
})
})
})

View File

@@ -1,21 +1,71 @@
import type { AltaCliente } from '../types'
import { uuid } from '../lib/uuid'
const API_URL = import.meta.env.VITE_API_URL ?? 'http://192.168.10.123:3000/api'
// TODO produccion: '/api' same-origin detras de NGINX
export interface AltaClienteCreada {
id: string
}
/** Registra el alta de cliente en el backend */
export async function crearAltaCliente(payload: AltaCliente): Promise<AltaClienteCreada> {
// TODO: POST /api/clientes
await new Promise((resolve) => setTimeout(resolve, 400))
console.log('[api] crearAltaCliente (stub):', payload.datosGenerales.nombreCompleto)
return { id: uuid() }
interface DocumentoMeta {
tipo: string
nombre: string
tamano: number
mime: string
}
/** Sube los documentos del expediente del cliente */
export async function subirDocumentos(clienteId: string, payload: AltaCliente): Promise<void> {
// TODO: POST multipart a /api/clientes/:id/documentos
await new Promise((resolve) => setTimeout(resolve, 200))
console.log(`[api] subirDocumentos (stub) cliente=${clienteId}:`, payload.documentos.length, 'archivos')
/**
* Serializa el alta para el POST JSON: los File no viajan en el cuerpo,
* solo sus metadatos; la firma ya es un string base64 (data URL).
*/
const serializarAlta = (payload: AltaCliente) => ({
...payload,
documentos: payload.documentos.map(
(d): DocumentoMeta => ({ tipo: d.slot, nombre: d.nombre, tamano: d.tamano, mime: d.tipoMime }),
),
})
/**
* Registra el alta en el API Gateway (POST /clientes). El backend persiste en
* SQLite y replica en Monday. Si el API no responde devuelve null (con
* console.warn) y el flujo continúa solo local: los PDFs se generan igual.
*/
export async function crearAltaCliente(payload: AltaCliente): Promise<AltaClienteCreada | null> {
try {
const res = await fetch(`${API_URL}/clientes`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(serializarAlta(payload)),
})
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const data = (await res.json()) as { id: string }
return { id: data.id }
} catch (err) {
console.warn('[api] crearAltaCliente: sin conexión al servidor, el expediente se genera solo localmente', err)
return null
}
}
/**
* Sube los documentos reales del expediente (POST multipart
* /clientes/:id/documentos, campo 'archivos'). El backend acepta un solo
* `tipo` por petición, así que se envía una petición por documento con su
* slot como tipo. Los fallos se reportan con console.warn sin interrumpir
* el flujo local.
*/
export async function subirDocumentos(clienteId: string, payload: AltaCliente): Promise<void> {
for (const doc of payload.documentos) {
try {
const form = new FormData()
form.append('tipo', doc.slot)
form.append('archivos', doc.archivo, doc.nombre)
const res = await fetch(`${API_URL}/clientes/${clienteId}/documentos`, {
method: 'POST',
body: form,
})
if (!res.ok) throw new Error(`HTTP ${res.status}`)
} catch (err) {
console.warn(`[api] subirDocumentos: no se pudo subir ${doc.nombre} (${doc.slot})`, err)
}
}
}

View File

@@ -1,10 +1,13 @@
import type { AltaCliente } from '../types'
/** Envía el alta al tablero de Monday (Contactos + Apartado) */
/**
* OBSOLETO: la réplica en Monday (tableros Contactos y Apartado) ya la hace
* el API Gateway en POST /api/clientes (ver services/api.ts y
* api-gateway/src/controllers/clientes.controller.ts). Se conserva el
* archivo solo como referencia; no llamar desde el flujo del wizard.
*/
export async function enviarAMonday(payload: AltaCliente): Promise<void> {
// TODO: integración Monday — crear item en tablero "Contactos" y "Apartado"
// con nombre, teléfono, correo, proyecto y ubicación del cliente
console.log('[monday] enviarAMonday (stub):', {
console.warn('[monday] enviarAMonday está obsoleto: el backend ya replica en Monday', {
nombre: payload.datosGenerales.nombreCompleto,
proyecto: payload.viviendaCredito.proyecto,
ubicacion: payload.viviendaCredito.ubicacion,

View File

@@ -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} />

View File

@@ -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">

View File

@@ -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>

View File

@@ -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

View File

@@ -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
}

View 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')
})
})

View File

@@ -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' }
}
}

View File

@@ -6,20 +6,24 @@ interface UseUnidadesResult {
unidades: Unidad[]
cargando: boolean
error: string | null
/** true cuando el inventario viene del seed local (sin conexión al API) */
usandoSeed: boolean
}
/** Carga el inventario de unidades (hoy desde seed; mañana desde el API Gateway) */
/** Carga el inventario de unidades desde el API Gateway, con fallback al seed local */
export function useUnidades(): UseUnidadesResult {
const [unidades, setUnidades] = useState<Unidad[]>([])
const [cargando, setCargando] = useState(true)
const [error, setError] = useState<string | null>(null)
const [usandoSeed, setUsandoSeed] = useState(false)
useEffect(() => {
let activo = true
getUnidades()
.then((data) => {
.then(({ unidades, usandoSeed }) => {
if (activo) {
setUnidades(data)
setUnidades(unidades)
setUsandoSeed(usandoSeed)
setCargando(false)
}
})
@@ -34,5 +38,5 @@ export function useUnidades(): UseUnidadesResult {
}
}, [])
return { unidades, cargando, error }
return { unidades, cargando, error, usandoSeed }
}

View File

@@ -37,7 +37,7 @@ const campoInputError = `${campoInputBase} border-red-400`
const etiquetaCampo = 'mb-2 block text-xs font-bold uppercase tracking-widest text-neutral-500'
export default function CotizadorPage() {
const { unidades, cargando, error } = useUnidades()
const { unidades, cargando, error, usandoSeed } = useUnidades()
const cotizacion = useCotizacion()
const {
unidad,
@@ -99,6 +99,9 @@ export default function CotizadorPage() {
<aside className="space-y-16">
<section className="space-y-8 border border-neutral-200 bg-white p-6">
{error && <p className="text-xs text-red-700">{error}</p>}
{usandoSeed && !cargando && (
<p className="text-xs text-amber-700">Datos de demostración (sin conexión al servidor)</p>
)}
{cargando ? (
<p className="text-sm text-neutral-500">Cargando unidades…</p>
) : (

View File

@@ -0,0 +1,67 @@
import { describe, it, expect, vi, afterEach } from 'vitest'
import { getUnidades } from './api'
import { UNIDADES_SEED } from '../data/unidades'
import type { Unidad } from '../types'
const unidadApi: Unidad = {
codigo: 'U-101',
modelo: '2RA',
fechaEntrega: '2027-10-08',
supEstacionamiento: 28.62,
supConstruccion: 69.485,
supExcedente: 21.336,
precioSinAcabados: 4415000,
precioConAcabados: 4579000,
status: 'DISPONIBLE',
desarrollo: 'DIAGONAL',
}
function respuestaOk(body: unknown): Response {
return new Response(JSON.stringify(body), {
status: 200,
headers: { 'Content-Type': 'application/json' },
})
}
afterEach(() => {
vi.unstubAllGlobals()
vi.restoreAllMocks()
})
describe('getUnidades', () => {
it('devuelve las unidades del API cuando la respuesta es OK', async () => {
const fetchMock = vi.fn().mockResolvedValue(respuestaOk([unidadApi]))
vi.stubGlobal('fetch', fetchMock)
const resultado = await getUnidades()
expect(fetchMock).toHaveBeenCalledWith('http://192.168.10.123:3000/api/unidades')
expect(resultado.usandoSeed).toBe(false)
expect(resultado.unidades).toEqual([unidadApi])
})
it('hace fallback al seed local cuando la respuesta no es OK', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue(new Response('error', { status: 500 })),
)
const resultado = await getUnidades()
expect(resultado.usandoSeed).toBe(true)
expect(resultado.unidades).toEqual(UNIDADES_SEED)
expect(warn).toHaveBeenCalledOnce()
})
it('hace fallback al seed local cuando falla la red', async () => {
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new TypeError('fetch failed')))
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
const resultado = await getUnidades()
expect(resultado.usandoSeed).toBe(true)
expect(resultado.unidades).toEqual(UNIDADES_SEED)
expect(warn).toHaveBeenCalledOnce()
})
})

View File

@@ -1,10 +1,28 @@
import type { Unidad } from '../types'
import { UNIDADES_SEED } from '../data/unidades'
// TODO produccion: '/api' same-origin detras de NGINX
const API_URL = import.meta.env.VITE_API_URL ?? 'http://192.168.10.123:3000/api'
export interface GetUnidadesResult {
unidades: Unidad[]
/** true cuando se usó el seed local por no poder contactar el API */
usandoSeed: boolean
}
/**
* Devuelve el inventario de unidades.
* TODO: reemplazar por fetch('/api/unidades') cuando el API Gateway esté disponible
* Intenta GET {API_URL}/unidades; si falla (red o respuesta no OK),
* hace fallback al seed local con console.warn.
*/
export async function getUnidades(): Promise<Unidad[]> {
return UNIDADES_SEED
export async function getUnidades(): Promise<GetUnidadesResult> {
try {
const resp = await fetch(`${API_URL}/unidades`)
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
const unidades = (await resp.json()) as Unidad[]
return { unidades, usandoSeed: false }
} catch (err) {
console.warn('[api] No se pudo obtener el inventario del API; usando seed local.', err)
return { unidades: UNIDADES_SEED, usandoSeed: true }
}
}

View File

@@ -8,6 +8,7 @@ export interface Unidad {
precioSinAcabados: number // MXN
precioConAcabados: number // MXN
status: 'DISPONIBLE' | 'APARTADA' | 'VENDIDA'
desarrollo?: string // lo agrega el API Gateway (Monday); el seed local no lo trae
}
export interface Cliente {