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

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