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