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
|
||||
|
||||
Reference in New Issue
Block a user