feat: ecosistema FRASA — apps Cotizador, Alta Clientes, Cobranza y Landing
- Cotizador (/quote): 4 propuestas con formulas validadas del Excel, comparador, combobox de unidades, vigencia, PDF ANEXO A - Alta Clientes (/client): wizard 8 pasos, validacion CURP RENAPO, OCR de INE, checklist de expediente, firma digital, 5 PDFs legales - Cobranza (/pay): planes de enganche, mora, vista cobranza del dia, recibos PDF, bitacora, dashboard con filtros - Landing (/): entrada al ecosistema - Design system StudioBlank (monocromo, acento #1B4332) en las 4 apps - 123 tests vitest
This commit is contained in:
359
apps/cobranza/src/pages/DashboardPage.tsx
Normal file
359
apps/cobranza/src/pages/DashboardPage.tsx
Normal file
@@ -0,0 +1,359 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { Link, useSearchParams } from 'react-router-dom'
|
||||
import {
|
||||
Bar,
|
||||
BarChart,
|
||||
CartesianGrid,
|
||||
Cell,
|
||||
Legend,
|
||||
Pie,
|
||||
PieChart,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from 'recharts'
|
||||
import type { PlanPago, StatusPlan } from '../types'
|
||||
import { cargarPlanes } from '../services/repository'
|
||||
import {
|
||||
alCorriente,
|
||||
avancePlan,
|
||||
diasAtraso,
|
||||
parseFechaLocal,
|
||||
proximoVencimiento,
|
||||
round2,
|
||||
saldoPlan,
|
||||
statusPlan,
|
||||
sumarMeses,
|
||||
} from '../lib/cobranza'
|
||||
import { formatoFechaCorta, formatoMoneda, formatoPorcentaje } from '../lib/formatters'
|
||||
import KpiCard from '../components/KpiCard'
|
||||
import SemaforoDot, { semaforoPlan } from '../components/Semaforo'
|
||||
|
||||
const MESES_CORTOS = ['ene', 'feb', 'mar', 'abr', 'may', 'jun', 'jul', 'ago', 'sep', 'oct', 'nov', 'dic']
|
||||
const COLORES_PIE = ['#1B4332', '#d4d4d4']
|
||||
|
||||
type ColumnaOrden = 'saldo' | 'vencimiento' | 'atraso' | 'avance'
|
||||
const COLUMNAS_ORDEN: ColumnaOrden[] = ['saldo', 'vencimiento', 'atraso', 'avance']
|
||||
|
||||
function esColumnaOrden(valor: string | null): valor is ColumnaOrden {
|
||||
return valor !== null && (COLUMNAS_ORDEN as string[]).includes(valor)
|
||||
}
|
||||
|
||||
function claveMes(fecha: Date): string {
|
||||
return `${fecha.getFullYear()}-${String(fecha.getMonth() + 1).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
export default function DashboardPage() {
|
||||
const [planes] = useState<PlanPago[]>(() => cargarPlanes())
|
||||
// Filtros y ordenamiento persistidos en la URL para que sean compartibles
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
const busqueda = searchParams.get('q') ?? ''
|
||||
const filtroStatus = (searchParams.get('status') ?? 'TODOS') as StatusPlan | 'TODOS'
|
||||
const filtroProyecto = searchParams.get('proyecto') ?? 'TODOS'
|
||||
const paramOrden = searchParams.get('sort')
|
||||
const orden: ColumnaOrden | null = esColumnaOrden(paramOrden) ? paramOrden : null
|
||||
const dir: 'asc' | 'desc' = searchParams.get('dir') === 'desc' ? 'desc' : 'asc'
|
||||
const hoy = useMemo(() => new Date(), [])
|
||||
|
||||
const actualizarParams = (cambios: Record<string, string | null>) => {
|
||||
setSearchParams(
|
||||
(prev) => {
|
||||
const next = new URLSearchParams(prev)
|
||||
for (const [clave, valor] of Object.entries(cambios)) {
|
||||
if (valor === null || valor === '') next.delete(clave)
|
||||
else next.set(clave, valor)
|
||||
}
|
||||
return next
|
||||
},
|
||||
{ replace: true },
|
||||
)
|
||||
}
|
||||
|
||||
const alternarOrden = (columna: ColumnaOrden) => {
|
||||
// Click sobre la columna activa alterna asc/desc; sobre otra, empieza en asc
|
||||
if (orden === columna) {
|
||||
actualizarParams({ sort: columna, dir: dir === 'asc' ? 'desc' : 'asc' })
|
||||
} else {
|
||||
actualizarParams({ sort: columna, dir: 'asc' })
|
||||
}
|
||||
}
|
||||
|
||||
const proyectos = useMemo(
|
||||
() => [...new Set(planes.map((p) => p.proyecto))].sort((a, b) => a.localeCompare(b)),
|
||||
[planes],
|
||||
)
|
||||
|
||||
const derivados = useMemo(
|
||||
() =>
|
||||
planes.map((plan) => ({
|
||||
plan,
|
||||
status: statusPlan(plan),
|
||||
saldo: saldoPlan(plan),
|
||||
avance: avancePlan(plan),
|
||||
corriente: alCorriente(plan, hoy),
|
||||
proximo: proximoVencimiento(plan, hoy),
|
||||
atraso: diasAtraso(plan, hoy),
|
||||
semaforo: semaforoPlan(plan, hoy),
|
||||
})),
|
||||
[planes, hoy],
|
||||
)
|
||||
|
||||
const activos = derivados.filter((d) => d.status === 'ACTIVO')
|
||||
const alCorrienteCount = activos.filter((d) => d.corriente).length
|
||||
const conAtraso = activos.length - alCorrienteCount
|
||||
const saldoTotal = round2(
|
||||
derivados.filter((d) => d.status !== 'CANCELADO').reduce((acc, d) => acc + d.saldo, 0),
|
||||
)
|
||||
|
||||
const mesActual = claveMes(hoy)
|
||||
const cobradoEsteMes = round2(
|
||||
planes
|
||||
.flatMap((p) => p.pagos.flatMap((pg) => pg.aplicaciones))
|
||||
.filter((a) => a.fechaPago.startsWith(mesActual))
|
||||
.reduce((acc, a) => acc + a.monto, 0),
|
||||
)
|
||||
|
||||
const datosPie = [
|
||||
{ name: 'Al corriente', value: alCorrienteCount },
|
||||
{ name: 'Con atraso', value: conAtraso },
|
||||
]
|
||||
|
||||
const datosBarras = useMemo(() => {
|
||||
const aplicaciones = planes.flatMap((p) => p.pagos.flatMap((pg) => pg.aplicaciones))
|
||||
const meses: { clave: string; etiqueta: string }[] = []
|
||||
for (let i = 5; i >= 0; i--) {
|
||||
const f = sumarMeses(new Date(hoy.getFullYear(), hoy.getMonth(), 1), -i)
|
||||
meses.push({ clave: claveMes(f), etiqueta: MESES_CORTOS[f.getMonth()] })
|
||||
}
|
||||
return meses.map(({ clave, etiqueta }) => ({
|
||||
mes: etiqueta,
|
||||
cobrado: round2(
|
||||
aplicaciones.filter((a) => a.fechaPago.startsWith(clave)).reduce((acc, a) => acc + a.monto, 0),
|
||||
),
|
||||
}))
|
||||
}, [planes, hoy])
|
||||
|
||||
const filtrados = derivados.filter((d) => {
|
||||
if (filtroStatus !== 'TODOS' && d.status !== filtroStatus) return false
|
||||
if (filtroProyecto !== 'TODOS' && d.plan.proyecto !== filtroProyecto) return false
|
||||
const texto = busqueda.trim().toLowerCase()
|
||||
if (!texto) return true
|
||||
return (
|
||||
d.plan.clienteNombre.toLowerCase().includes(texto) ||
|
||||
d.plan.unidadCodigo.toLowerCase().includes(texto)
|
||||
)
|
||||
})
|
||||
|
||||
const valorOrden = (d: (typeof filtrados)[number]): number => {
|
||||
switch (orden) {
|
||||
case 'saldo':
|
||||
return d.saldo
|
||||
case 'vencimiento':
|
||||
// Planes cubiertos (sin próximo vencimiento) siempre al final
|
||||
return d.proximo ? parseFechaLocal(d.proximo.fechaVencimiento).getTime() : Number.MAX_SAFE_INTEGER
|
||||
case 'atraso':
|
||||
return d.atraso
|
||||
case 'avance':
|
||||
return d.avance
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
const ordenados = (() => {
|
||||
if (!orden) return filtrados
|
||||
const factor = dir === 'desc' ? -1 : 1
|
||||
return [...filtrados].sort((a, b) => (valorOrden(a) - valorOrden(b)) * factor)
|
||||
})()
|
||||
|
||||
const encabezadoOrdenable = (columna: ColumnaOrden, titulo: string, alineadoDerecha = false) => (
|
||||
<th scope="col" className={`py-3 pr-4 ${alineadoDerecha ? 'text-right' : ''}`}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => alternarOrden(columna)}
|
||||
aria-label={`Ordenar por ${titulo}`}
|
||||
className="inline-flex items-center gap-1 font-bold uppercase tracking-widest transition-colors duration-150 hover:text-neutral-900"
|
||||
>
|
||||
{titulo}
|
||||
{orden === columna && <span aria-hidden="true">{dir === 'asc' ? '↑' : '↓'}</span>}
|
||||
</button>
|
||||
</th>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="space-y-16">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-3xl font-light text-neutral-900">Módulo Cobranza — Planes de enganche</h1>
|
||||
<Link
|
||||
to="/nuevo"
|
||||
className="bg-accent px-5 py-2.5 text-xs font-bold uppercase tracking-widest text-white transition-colors duration-150 hover:opacity-90"
|
||||
>
|
||||
Nuevo plan
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* KPIs */}
|
||||
<div className="grid grid-cols-2 gap-6 lg:grid-cols-4">
|
||||
<KpiCard titulo="Planes activos" valor={activos.length} />
|
||||
<KpiCard
|
||||
titulo="Al corriente / con atraso"
|
||||
valor={`${alCorrienteCount} / ${conAtraso}`}
|
||||
detalle="Solo planes activos"
|
||||
/>
|
||||
<KpiCard titulo="Saldo por cobrar" valor={formatoMoneda(saldoTotal)} />
|
||||
<KpiCard titulo="Cobrado este mes" valor={formatoMoneda(cobradoEsteMes)} />
|
||||
</div>
|
||||
|
||||
{/* Gráficas */}
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
<div className="border border-neutral-200 bg-white p-6">
|
||||
<h2 className="mb-6 text-xs font-bold uppercase tracking-widest text-neutral-500">
|
||||
Al corriente vs vencidos
|
||||
</h2>
|
||||
<ResponsiveContainer width="100%" height={240}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={datosPie}
|
||||
dataKey="value"
|
||||
nameKey="name"
|
||||
innerRadius={50}
|
||||
outerRadius={80}
|
||||
strokeWidth={0}
|
||||
label
|
||||
>
|
||||
{datosPie.map((_, i) => (
|
||||
<Cell key={i} fill={COLORES_PIE[i % COLORES_PIE.length]} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip />
|
||||
<Legend />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
<div className="border border-neutral-200 bg-white p-6">
|
||||
<h2 className="mb-6 text-xs font-bold uppercase tracking-widest text-neutral-500">
|
||||
Cobrado por mes (últimos 6 meses)
|
||||
</h2>
|
||||
<ResponsiveContainer width="100%" height={240}>
|
||||
<BarChart data={datosBarras}>
|
||||
<CartesianGrid stroke="#e5e5e5" strokeDasharray="3 3" vertical={false} />
|
||||
<XAxis dataKey="mes" tickLine={false} axisLine={{ stroke: '#e5e5e5' }} />
|
||||
<YAxis
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickFormatter={(v: number) => `$${(v / 1000).toLocaleString('es-MX')}k`}
|
||||
/>
|
||||
<Tooltip formatter={(v) => formatoMoneda(Number(v))} cursor={{ fill: '#fafafa' }} />
|
||||
<Bar dataKey="cobrado" fill="#1B4332" name="Cobrado" />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filtros */}
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
<label htmlFor="filtro-busqueda" className="sr-only">
|
||||
Buscar por cliente o unidad
|
||||
</label>
|
||||
<input
|
||||
id="filtro-busqueda"
|
||||
type="search"
|
||||
value={busqueda}
|
||||
onChange={(e) => actualizarParams({ q: e.target.value })}
|
||||
placeholder="Buscar por cliente o unidad…"
|
||||
className="w-64 border border-neutral-300 bg-white px-3 py-2.5 text-sm transition-colors duration-150 focus:border-neutral-900 focus:outline-none"
|
||||
/>
|
||||
<label htmlFor="filtro-status" className="sr-only">
|
||||
Filtrar por status
|
||||
</label>
|
||||
<select
|
||||
id="filtro-status"
|
||||
value={filtroStatus}
|
||||
onChange={(e) => actualizarParams({ status: e.target.value === 'TODOS' ? null : e.target.value })}
|
||||
className="border border-neutral-300 bg-white px-3 py-2.5 text-sm transition-colors duration-150 focus:border-neutral-900 focus:outline-none"
|
||||
>
|
||||
<option value="TODOS">Todos los status</option>
|
||||
<option value="ACTIVO">ACTIVO</option>
|
||||
<option value="LIQUIDADO">LIQUIDADO</option>
|
||||
<option value="CANCELADO">CANCELADO</option>
|
||||
</select>
|
||||
<label htmlFor="filtro-proyecto" className="sr-only">
|
||||
Filtrar por proyecto
|
||||
</label>
|
||||
<select
|
||||
id="filtro-proyecto"
|
||||
value={filtroProyecto}
|
||||
onChange={(e) => actualizarParams({ proyecto: e.target.value === 'TODOS' ? null : e.target.value })}
|
||||
className="border border-neutral-300 bg-white px-3 py-2.5 text-sm transition-colors duration-150 focus:border-neutral-900 focus:outline-none"
|
||||
>
|
||||
<option value="TODOS">Todos los proyectos</option>
|
||||
{proyectos.map((p) => (
|
||||
<option key={p} value={p}>
|
||||
{p}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Tabla de planes */}
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-neutral-900 text-xs font-bold uppercase tracking-widest text-neutral-500">
|
||||
<th scope="col" className="py-3 pr-4">Cliente</th>
|
||||
<th scope="col" className="py-3 pr-4">Unidad</th>
|
||||
<th scope="col" className="py-3 pr-4">Proyecto</th>
|
||||
{encabezadoOrdenable('avance', '% pagado')}
|
||||
{encabezadoOrdenable('vencimiento', 'Próx. vencimiento')}
|
||||
{encabezadoOrdenable('saldo', 'Saldo', true)}
|
||||
{encabezadoOrdenable('atraso', 'Días atraso', true)}
|
||||
<th scope="col" className="py-3">Estado</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{ordenados.map((d) => (
|
||||
<tr key={d.plan.id} className="border-b border-neutral-200 transition-colors duration-150 hover:bg-neutral-50">
|
||||
<td className="py-3 pr-4">
|
||||
<Link to={`/planes/${d.plan.id}`} className="font-medium text-accent hover:underline">
|
||||
{d.plan.clienteNombre}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="py-3 pr-4">{d.plan.unidadCodigo}</td>
|
||||
<td className="py-3 pr-4">{d.plan.proyecto}</td>
|
||||
<td className="py-3 pr-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-1 w-24 bg-neutral-200">
|
||||
<div
|
||||
className="h-full bg-accent"
|
||||
style={{ width: `${Math.min(100, d.avance * 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-neutral-500">{formatoPorcentaje(d.avance)}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-3 pr-4">
|
||||
{d.proximo ? formatoFechaCorta(d.proximo.fechaVencimiento) : '—'}
|
||||
</td>
|
||||
<td className="py-3 pr-4 text-right font-medium">{formatoMoneda(d.saldo)}</td>
|
||||
<td className="py-3 pr-4 text-right">
|
||||
{d.atraso > 0 ? <span className="text-red-700">{d.atraso}</span> : '0'}
|
||||
</td>
|
||||
<td className="py-3">
|
||||
<SemaforoDot valor={d.semaforo} />
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{ordenados.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={8} className="py-8 text-center text-neutral-500">
|
||||
No hay planes que coincidan con el filtro.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
102
apps/cobranza/src/pages/HoyPage.tsx
Normal file
102
apps/cobranza/src/pages/HoyPage.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import type { PlanPago } from '../types'
|
||||
import { cargarPlanes } 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 hoy = useMemo(() => new Date(), [])
|
||||
const items = useMemo(() => cobranzaDelDia(planes, hoy), [planes, hoy])
|
||||
|
||||
const vencidos = items.filter((i) => i.atraso > 0)
|
||||
const porVencer = items.length - vencidos.length
|
||||
const totalPorCobrar = round2(
|
||||
items.reduce((acc, i) => acc + (i.pago.monto - i.pago.montoPagado), 0),
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="space-y-16">
|
||||
<div>
|
||||
<h1 className="text-3xl font-light text-neutral-900">Cobranza del día</h1>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-6 lg:grid-cols-3">
|
||||
<KpiCard titulo="Pagos vencidos" valor={vencidos.length} />
|
||||
<KpiCard titulo="Por vencer (7 días)" valor={porVencer} />
|
||||
<KpiCard titulo="Monto por cobrar" valor={formatoMoneda(totalPorCobrar)} />
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-neutral-900 text-xs font-bold uppercase tracking-widest text-neutral-500">
|
||||
<th scope="col" className="py-3 pr-4">Cliente</th>
|
||||
<th scope="col" className="py-3 pr-4">Teléfono</th>
|
||||
<th scope="col" className="py-3 pr-4">Unidad</th>
|
||||
<th scope="col" className="py-3 pr-4"># pago</th>
|
||||
<th scope="col" className="py-3 pr-4">Vencimiento</th>
|
||||
<th scope="col" className="py-3 pr-4 text-right">Monto</th>
|
||||
<th scope="col" className="py-3 pr-4 text-right">Restante</th>
|
||||
<th scope="col" className="py-3 pr-4 text-right">Días atraso</th>
|
||||
<th scope="col" className="py-3">Plan</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map(({ plan, pago, atraso }) => (
|
||||
<tr
|
||||
key={`${plan.id}-${pago.id}`}
|
||||
className="border-b border-neutral-200 transition-colors duration-150 hover:bg-neutral-50"
|
||||
>
|
||||
<td className="py-3 pr-4 font-medium">{plan.clienteNombre}</td>
|
||||
<td className="py-3 pr-4">
|
||||
{plan.clienteTelefono ? (
|
||||
<a href={`tel:${plan.clienteTelefono.replace(/\s/g, '')}`} className="text-accent hover:underline">
|
||||
{plan.clienteTelefono}
|
||||
</a>
|
||||
) : (
|
||||
<span className="text-neutral-400">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-3 pr-4">{plan.unidadCodigo}</td>
|
||||
<td className="py-3 pr-4">{pago.numPago}</td>
|
||||
<td className="py-3 pr-4">{formatoFechaCorta(pago.fechaVencimiento)}</td>
|
||||
<td className="py-3 pr-4 text-right">{formatoMoneda(pago.monto)}</td>
|
||||
<td className="py-3 pr-4 text-right font-medium">
|
||||
{formatoMoneda(round2(pago.monto - pago.montoPagado))}
|
||||
</td>
|
||||
<td className="py-3 pr-4 text-right">
|
||||
{atraso > 0 ? (
|
||||
<span className="font-medium text-red-700">{atraso}</span>
|
||||
) : (
|
||||
<span className="text-amber-700">
|
||||
{statusPago(pago, hoy) === 'PARCIAL' ? 'Parcial' : 'Por vencer'}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-3">
|
||||
<Link to={`/planes/${plan.id}`} className="text-accent hover:underline">
|
||||
Ver plan
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{items.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={9} className="py-8 text-center text-neutral-500">
|
||||
No hay pagos vencidos ni vencimientos en los próximos 7 días.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
213
apps/cobranza/src/pages/NuevoPlanPage.tsx
Normal file
213
apps/cobranza/src/pages/NuevoPlanPage.tsx
Normal file
@@ -0,0 +1,213 @@
|
||||
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 { generarCalendario } from '../lib/cobranza'
|
||||
import { fechaISOHoy, formatoFechaCorta, formatoMoneda } from '../lib/formatters'
|
||||
|
||||
const PROYECTOS = ['RESIDENCIAL MONTEBELLO', 'SENDA', 'DIAGONAL'] as const
|
||||
|
||||
const esquema = z.object({
|
||||
clienteNombre: z.string().trim().min(3, 'Nombre del cliente requerido (mín. 3 caracteres)'),
|
||||
clienteTelefono: z.string().trim().optional(),
|
||||
unidadCodigo: z.string().trim().min(1, 'Código de unidad requerido'),
|
||||
proyecto: z.enum(PROYECTOS),
|
||||
ventaRef: z.string().trim(),
|
||||
fechaInicio: z.string().min(1, 'Fecha de inicio requerida'),
|
||||
engancheTotal: z
|
||||
.number({ invalid_type_error: 'Captura un monto válido' })
|
||||
.positive('El enganche debe ser mayor a 0'),
|
||||
numPagos: z
|
||||
.number({ invalid_type_error: 'Captura un número válido' })
|
||||
.int('Debe ser un número entero')
|
||||
.min(1, 'Mínimo 1 pago')
|
||||
.max(36, 'Máximo 36 pagos'),
|
||||
tasaMoraMensual: z.preprocess(
|
||||
(v) => (v === '' || v === undefined || v === null ? undefined : Number(v)),
|
||||
z
|
||||
.number({ invalid_type_error: 'Captura una tasa válida' })
|
||||
.min(0, 'La tasa no puede ser negativa')
|
||||
.max(30, 'Tasa máxima 30%')
|
||||
.optional(),
|
||||
),
|
||||
})
|
||||
|
||||
type NuevoPlanForm = z.infer<typeof esquema>
|
||||
|
||||
const claseInput =
|
||||
'mt-1 w-full border border-neutral-300 bg-white px-3 py-2.5 text-sm transition-colors duration-150 focus:border-neutral-900 focus:outline-none'
|
||||
const claseError = 'mt-1 text-xs text-red-700'
|
||||
const claseLabel = 'block text-xs font-bold uppercase tracking-widest text-neutral-500'
|
||||
|
||||
export default function NuevoPlanPage() {
|
||||
const navigate = useNavigate()
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
watch,
|
||||
formState: { errors },
|
||||
} = useForm<NuevoPlanForm>({
|
||||
resolver: zodResolver(esquema),
|
||||
defaultValues: {
|
||||
clienteNombre: '',
|
||||
unidadCodigo: '',
|
||||
proyecto: 'SENDA',
|
||||
ventaRef: '',
|
||||
fechaInicio: fechaISOHoy(),
|
||||
numPagos: 6,
|
||||
},
|
||||
})
|
||||
|
||||
const [fechaInicio, engancheTotal, numPagos] = watch(['fechaInicio', 'engancheTotal', 'numPagos'])
|
||||
|
||||
// Vista previa en vivo del calendario; solo si los valores son válidos
|
||||
let vistaPrevia: ReturnType<typeof generarCalendario> | null = null
|
||||
try {
|
||||
if (fechaInicio && engancheTotal > 0 && Number.isInteger(numPagos) && numPagos >= 1 && numPagos <= 36) {
|
||||
vistaPrevia = generarCalendario(fechaInicio, engancheTotal, numPagos)
|
||||
}
|
||||
} catch {
|
||||
vistaPrevia = null
|
||||
}
|
||||
|
||||
const guardar = (datos: NuevoPlanForm) => {
|
||||
const plan = crearPlan(datos)
|
||||
void navigate(`/planes/${plan.id}`)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl space-y-16">
|
||||
<h1 className="text-3xl font-light text-neutral-900">Nuevo plan de enganche</h1>
|
||||
|
||||
<form
|
||||
className="border border-neutral-200 bg-white p-8"
|
||||
onSubmit={(e) => {
|
||||
void handleSubmit(guardar)(e)
|
||||
}}
|
||||
>
|
||||
<div className="grid gap-6 sm:grid-cols-2">
|
||||
<div className="sm:col-span-2">
|
||||
<label htmlFor="clienteNombre" className={claseLabel}>Cliente *</label>
|
||||
<input id="clienteNombre" type="text" className={claseInput} {...register('clienteNombre')} />
|
||||
{errors.clienteNombre && <p className={claseError}>{errors.clienteNombre.message}</p>}
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="clienteTelefono" className={claseLabel}>Teléfono de contacto</label>
|
||||
<input
|
||||
id="clienteTelefono"
|
||||
type="tel"
|
||||
className={claseInput}
|
||||
placeholder="55 1234 5678"
|
||||
{...register('clienteTelefono')}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="unidadCodigo" className={claseLabel}>Unidad *</label>
|
||||
<input id="unidadCodigo" type="text" className={claseInput} placeholder="U-101" {...register('unidadCodigo')} />
|
||||
{errors.unidadCodigo && <p className={claseError}>{errors.unidadCodigo.message}</p>}
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="proyecto" className={claseLabel}>Proyecto *</label>
|
||||
<select id="proyecto" className={claseInput} {...register('proyecto')}>
|
||||
{PROYECTOS.map((p) => (
|
||||
<option key={p} value={p}>
|
||||
{p}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="ventaRef" className={claseLabel}>Referencia de venta</label>
|
||||
<input id="ventaRef" type="text" className={claseInput} placeholder="VTA-2026-001" {...register('ventaRef')} />
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="fechaInicio" className={claseLabel}>Primer vencimiento *</label>
|
||||
<input id="fechaInicio" type="date" className={claseInput} {...register('fechaInicio')} />
|
||||
{errors.fechaInicio && <p className={claseError}>{errors.fechaInicio.message}</p>}
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="engancheTotal" className={claseLabel}>Enganche total (MXN) *</label>
|
||||
<input
|
||||
id="engancheTotal"
|
||||
type="number"
|
||||
step="0.01"
|
||||
className={claseInput}
|
||||
{...register('engancheTotal', { valueAsNumber: true })}
|
||||
/>
|
||||
{errors.engancheTotal && <p className={claseError}>{errors.engancheTotal.message}</p>}
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="numPagos" className={claseLabel}>Número de pagos *</label>
|
||||
<input
|
||||
id="numPagos"
|
||||
type="number"
|
||||
step="1"
|
||||
className={claseInput}
|
||||
{...register('numPagos', { valueAsNumber: true })}
|
||||
/>
|
||||
{errors.numPagos && <p className={claseError}>{errors.numPagos.message}</p>}
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="tasaMoraMensual" className={claseLabel}>Tasa de mora mensual (%)</label>
|
||||
<input
|
||||
id="tasaMoraMensual"
|
||||
type="number"
|
||||
step="0.1"
|
||||
min="0"
|
||||
className={claseInput}
|
||||
placeholder="0 = sin mora"
|
||||
{...register('tasaMoraMensual')}
|
||||
/>
|
||||
{errors.tasaMoraMensual && <p className={claseError}>{errors.tasaMoraMensual.message}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Vista previa del calendario */}
|
||||
{vistaPrevia && (
|
||||
<div className="mt-8 border border-neutral-200 bg-white p-6">
|
||||
<h2 className="text-xs font-bold uppercase tracking-widest text-neutral-500">
|
||||
Vista previa — {vistaPrevia.pagos.length} pagos de {formatoMoneda(vistaPrevia.mensualidad)}
|
||||
</h2>
|
||||
<div className="mt-4 max-h-48 overflow-y-auto">
|
||||
<table className="w-full text-left text-xs">
|
||||
<thead className="text-neutral-500">
|
||||
<tr className="border-b border-neutral-900">
|
||||
<th scope="col" className="py-1">#</th>
|
||||
<th scope="col" className="py-1">Vencimiento</th>
|
||||
<th scope="col" className="py-1 text-right">Monto</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{vistaPrevia.pagos.map((p) => (
|
||||
<tr key={p.numPago} className="border-b border-neutral-200">
|
||||
<td className="py-1">{p.numPago}</td>
|
||||
<td className="py-1">{formatoFechaCorta(p.fechaVencimiento)}</td>
|
||||
<td className="py-1 text-right">{formatoMoneda(p.monto)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-8 flex justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void navigate('/')}
|
||||
className="border border-neutral-900 bg-transparent px-5 py-2.5 text-xs font-bold uppercase tracking-widest text-neutral-900 transition-colors duration-150 hover:bg-neutral-900 hover:text-white"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
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
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
324
apps/cobranza/src/pages/PlanDetallePage.tsx
Normal file
324
apps/cobranza/src/pages/PlanDetallePage.tsx
Normal file
@@ -0,0 +1,324 @@
|
||||
import { Fragment, 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 {
|
||||
avancePlan,
|
||||
diasAtraso,
|
||||
moraDePago,
|
||||
pagadoTotal,
|
||||
round2,
|
||||
saldoPlan,
|
||||
statusPago,
|
||||
statusPlan,
|
||||
totalMora,
|
||||
} from '../lib/cobranza'
|
||||
import { formatoFechaCorta, formatoMoneda, formatoPorcentaje } from '../lib/formatters'
|
||||
import { descargar } from '../lib/pdf/estadoCuenta'
|
||||
import { descargarRecibo } from '../lib/pdf/reciboAbono'
|
||||
import BadgeStatus from '../components/BadgeStatus'
|
||||
import KpiCard from '../components/KpiCard'
|
||||
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 [pagoAbono, setPagoAbono] = useState<Pago | null>(null)
|
||||
const [expandidos, setExpandidos] = useState<Set<string>>(new Set())
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [notaTexto, setNotaTexto] = useState('')
|
||||
const [errorNota, setErrorNota] = useState<string | null>(null)
|
||||
const hoy = useMemo(() => new Date(), [])
|
||||
|
||||
if (!plan) {
|
||||
return (
|
||||
<div className="border border-neutral-200 bg-white p-16 text-center">
|
||||
<p className="text-neutral-500">Plan no encontrado.</p>
|
||||
<Link to="/" className="mt-4 inline-block text-accent hover:underline">
|
||||
Volver al dashboard
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const status = statusPlan(plan)
|
||||
const saldo = saldoPlan(plan)
|
||||
const atraso = diasAtraso(plan, hoy)
|
||||
const tasaMora = plan.tasaMoraMensual ?? 0
|
||||
const mora = totalMora(plan, hoy)
|
||||
|
||||
const toggleExpandido = (pagoId: string) => {
|
||||
setExpandidos((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(pagoId)) next.delete(pagoId)
|
||||
else next.add(pagoId)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const guardarAbono = (datos: { monto: number; fechaPago: string; referencia: string }) => {
|
||||
if (!pagoAbono) return
|
||||
try {
|
||||
const actualizado = registrarAbonoEnPlan(plan.id, datos, pagoAbono.id)
|
||||
setPlan(actualizado)
|
||||
setPagoAbono(null)
|
||||
setError(null)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Error al registrar el abono')
|
||||
}
|
||||
}
|
||||
|
||||
const cancelar = () => {
|
||||
if (!window.confirm('¿Cancelar este plan? El saldo pendiente dejará de cobrarse.')) return
|
||||
setPlan(cancelarPlan(plan.id))
|
||||
}
|
||||
|
||||
const guardarNota = () => {
|
||||
try {
|
||||
setPlan(agregarNota(plan.id, notaTexto))
|
||||
setNotaTexto('')
|
||||
setErrorNota(null)
|
||||
} catch (e) {
|
||||
setErrorNota(e instanceof Error ? e.message : 'Error al guardar la nota')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-16">
|
||||
<div className="flex flex-wrap items-start justify-between gap-6">
|
||||
<div>
|
||||
<Link to="/" className="text-sm text-accent hover:underline">
|
||||
← Volver al dashboard
|
||||
</Link>
|
||||
<h1 className="mt-2 text-3xl font-light text-neutral-900">{plan.clienteNombre}</h1>
|
||||
<p className="mt-1 text-sm text-neutral-500">
|
||||
{plan.unidadCodigo} · {plan.proyecto} · Venta {plan.ventaRef || '—'} ·{' '}
|
||||
<span className="font-bold text-neutral-900">{status}</span>
|
||||
{atraso > 0 && <span className="text-red-700"> · {atraso} días de atraso</span>}
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-neutral-500">
|
||||
Tel. {plan.clienteTelefono || '—'}
|
||||
{tasaMora > 0 && <span> · Mora {tasaMora}% mensual</span>}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={() => descargar(plan)}
|
||||
className="border border-neutral-900 bg-transparent px-5 py-2.5 text-xs font-bold uppercase tracking-widest text-neutral-900 transition-colors duration-150 hover:bg-neutral-900 hover:text-white"
|
||||
>
|
||||
Estado de cuenta PDF
|
||||
</button>
|
||||
{status === 'ACTIVO' && saldo > 0 && (
|
||||
<button
|
||||
onClick={cancelar}
|
||||
className="border border-red-300 bg-white px-5 py-2.5 text-xs font-bold uppercase tracking-widest text-red-700 transition-colors duration-150 hover:bg-red-700 hover:text-white"
|
||||
>
|
||||
Cancelar plan
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="border border-red-300 bg-white px-4 py-3 text-sm text-red-700">{error}</div>
|
||||
)}
|
||||
|
||||
{/* KPIs del plan */}
|
||||
<div className="grid grid-cols-2 gap-6 lg:grid-cols-5">
|
||||
<KpiCard titulo="Enganche total" valor={formatoMoneda(plan.engancheTotal)} />
|
||||
<KpiCard titulo="Pagado" valor={formatoMoneda(pagadoTotal(plan))} />
|
||||
<KpiCard titulo="Saldo" valor={formatoMoneda(saldo)} />
|
||||
<KpiCard titulo="% avance" valor={formatoPorcentaje(avancePlan(plan))} />
|
||||
<KpiCard titulo="Mensualidad" valor={formatoMoneda(plan.mensualidad)} detalle={`${plan.numPagos} pagos`} />
|
||||
{mora > 0 && (
|
||||
<KpiCard
|
||||
titulo="Mora acumulada"
|
||||
valor={<span className="text-red-700">{formatoMoneda(mora)}</span>}
|
||||
detalle={`Tasa ${tasaMora}% mensual`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tabla de pagos */}
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-neutral-900 text-xs font-bold uppercase tracking-widest text-neutral-500">
|
||||
<th scope="col" className="py-3 pr-4">#</th>
|
||||
<th scope="col" className="py-3 pr-4">Vencimiento</th>
|
||||
<th scope="col" className="py-3 pr-4 text-right">Monto</th>
|
||||
<th scope="col" className="py-3 pr-4 text-right">Pagado</th>
|
||||
<th scope="col" className="py-3 pr-4 text-right">Restante</th>
|
||||
{tasaMora > 0 && <th scope="col" className="py-3 pr-4 text-right">Mora</th>}
|
||||
<th scope="col" className="py-3 pr-4">Status</th>
|
||||
<th scope="col" className="py-3 pr-4">Abonos</th>
|
||||
<th scope="col" className="py-3" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{plan.pagos.map((pago) => {
|
||||
const st = statusPago(pago, hoy)
|
||||
const restante = round2(pago.monto - pago.montoPagado)
|
||||
const moraPago = tasaMora > 0 ? moraDePago(pago, tasaMora, hoy) : 0
|
||||
const expandido = expandidos.has(pago.id)
|
||||
return (
|
||||
<Fragment key={pago.id}>
|
||||
<tr className="border-b border-neutral-200 transition-colors duration-150 hover:bg-neutral-50">
|
||||
<td className="py-3 pr-4 font-medium">{pago.numPago}</td>
|
||||
<td className="py-3 pr-4">{formatoFechaCorta(pago.fechaVencimiento)}</td>
|
||||
<td className="py-3 pr-4 text-right">{formatoMoneda(pago.monto)}</td>
|
||||
<td className="py-3 pr-4 text-right">{formatoMoneda(pago.montoPagado)}</td>
|
||||
<td className="py-3 pr-4 text-right font-medium">{formatoMoneda(restante)}</td>
|
||||
{tasaMora > 0 && (
|
||||
<td className="py-3 pr-4 text-right">
|
||||
{moraPago > 0 ? (
|
||||
<span className="text-red-700">{formatoMoneda(moraPago)}</span>
|
||||
) : (
|
||||
<span className="text-neutral-400">—</span>
|
||||
)}
|
||||
</td>
|
||||
)}
|
||||
<td className="py-3 pr-4">
|
||||
<BadgeStatus status={st} />
|
||||
</td>
|
||||
<td className="py-3 pr-4">
|
||||
{pago.aplicaciones.length > 0 ? (
|
||||
<button
|
||||
onClick={() => toggleExpandido(pago.id)}
|
||||
className="text-accent hover:underline"
|
||||
>
|
||||
{expandido ? 'Ocultar' : `Ver (${pago.aplicaciones.length})`}
|
||||
</button>
|
||||
) : (
|
||||
<span className="text-neutral-400">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-3 text-right">
|
||||
{st !== 'PAGADO' && status === 'ACTIVO' && (
|
||||
<button
|
||||
onClick={() => setPagoAbono(pago)}
|
||||
className="bg-accent px-4 py-2 text-xs font-bold uppercase tracking-widest text-white transition-colors duration-150 hover:opacity-90"
|
||||
>
|
||||
Registrar abono
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
{expandido && (
|
||||
<tr className="border-b border-neutral-200 bg-neutral-50">
|
||||
<td colSpan={tasaMora > 0 ? 9 : 8} className="px-4 py-4">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="text-left font-bold uppercase tracking-widest text-neutral-500">
|
||||
<th scope="col" className="py-1">Fecha</th>
|
||||
<th scope="col" className="py-1 text-right">Monto</th>
|
||||
<th scope="col" className="py-1">Referencia</th>
|
||||
<th scope="col" className="py-1 text-right">Recibo</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{pago.aplicaciones.map((a) => (
|
||||
<tr key={a.id} className="border-t border-neutral-200">
|
||||
<td className="py-1">{formatoFechaCorta(a.fechaPago)}</td>
|
||||
<td className="py-1 text-right">{formatoMoneda(a.monto)}</td>
|
||||
<td className="py-1">{a.referencia}</td>
|
||||
<td className="py-1 text-right">
|
||||
<button
|
||||
onClick={() => descargarRecibo(plan, pago, a)}
|
||||
aria-label={`Descargar recibo PDF del abono ${a.referencia}`}
|
||||
className="text-accent hover:underline"
|
||||
>
|
||||
Recibo PDF
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</Fragment>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Bitácora del plan */}
|
||||
<section className="border border-neutral-200 bg-white p-8" aria-labelledby="titulo-bitacora">
|
||||
<h2 id="titulo-bitacora" className="text-xs font-bold uppercase tracking-widest text-neutral-500">
|
||||
Bitácora
|
||||
</h2>
|
||||
{(plan.notas ?? []).length === 0 ? (
|
||||
<p className="mt-6 text-sm text-neutral-500">Sin notas registradas.</p>
|
||||
) : (
|
||||
<ul className="mt-6 space-y-4">
|
||||
{[...(plan.notas ?? [])]
|
||||
.sort((a, b) => b.fecha.localeCompare(a.fecha))
|
||||
.map((nota) => (
|
||||
<li key={nota.id} className="border-l-2 border-neutral-200 pl-4">
|
||||
<p className="text-xs text-neutral-500">
|
||||
{new Date(nota.fecha).toLocaleString('es-MX', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})}
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-neutral-900">{nota.texto}</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<form
|
||||
className="mt-8"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
guardarNota()
|
||||
}}
|
||||
>
|
||||
<label
|
||||
htmlFor="nota-texto"
|
||||
className="block text-xs font-bold uppercase tracking-widest text-neutral-500"
|
||||
>
|
||||
Nueva nota
|
||||
</label>
|
||||
<textarea
|
||||
id="nota-texto"
|
||||
value={notaTexto}
|
||||
onChange={(e) => setNotaTexto(e.target.value)}
|
||||
rows={3}
|
||||
placeholder="Gestión realizada, acuerdos con el cliente…"
|
||||
className="mt-1 w-full border border-neutral-300 bg-white px-3 py-2.5 text-sm transition-colors duration-150 focus:border-neutral-900 focus:outline-none"
|
||||
/>
|
||||
{errorNota && <p className="mt-1 text-xs text-red-700">{errorNota}</p>}
|
||||
<div className="mt-4 flex justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={notaTexto.trim().length < 3}
|
||||
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"
|
||||
>
|
||||
Agregar nota
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
{status === 'CANCELADO' && (
|
||||
<button
|
||||
onClick={() => void navigate('/')}
|
||||
className="border border-neutral-900 bg-transparent px-5 py-2.5 text-xs font-bold uppercase tracking-widest text-neutral-900 transition-colors duration-150 hover:bg-neutral-900 hover:text-white"
|
||||
>
|
||||
Volver
|
||||
</button>
|
||||
)}
|
||||
|
||||
{pagoAbono && (
|
||||
<ModalAbono pago={pagoAbono} onCancelar={() => setPagoAbono(null)} onGuardar={guardarAbono} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user