feat(mejoras): 22 mejoras UX en todos los módulos
Mi día: cancelación con motivo, conteo escrituración real, cita sin retroceder, calendario completo Cobranza: MoraDashboard respeta alcance, hoy dinámico, cancelar con motivo Landing: contactos paginados, error vs vacío diferenciado Dashboards: KPI renombrado, drill-down planes activos Chat: historial recarga, preguntas rotativas, error solo toast Transversal: shadow-sm corrupto corregido en 9 archivos 553 API tests + 7 apps construidas
This commit is contained in:
79
apps/cobranza/src/components/ModalCancelarPlan.tsx
Normal file
79
apps/cobranza/src/components/ModalCancelarPlan.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import { useState, type FormEvent } from 'react'
|
||||
|
||||
/**
|
||||
* Motivos de cancelación: mismas categorías del vocabulario CRM FRASA
|
||||
* (MOTIVOS_NO_COMPRA) que usa el descarte en Mi día; el motivo queda registrado
|
||||
* en la bitácora del plan.
|
||||
*/
|
||||
const MOTIVOS_CANCELACION = ['Precio', 'Producto', 'Ubicacion', 'Financiamiento', 'Competencia', 'Cliente', 'Timing'] as const
|
||||
|
||||
/**
|
||||
* Modal de cancelación de plan: pide motivo (+ comentario opcional) antes de
|
||||
* dar de baja el cobro. Sustituye al window.confirm anterior.
|
||||
*/
|
||||
export default function ModalCancelarPlan({
|
||||
clienteNombre,
|
||||
onCancelar,
|
||||
onConfirmar,
|
||||
enviando,
|
||||
}: {
|
||||
clienteNombre: string
|
||||
onCancelar: () => void
|
||||
onConfirmar: (motivo: string, comentario: string) => void
|
||||
enviando: boolean
|
||||
}) {
|
||||
const [motivo, setMotivo] = useState<string>('Timing')
|
||||
const [comentario, setComentario] = useState('')
|
||||
return (
|
||||
<div className="fixed inset-0 z-20 flex items-center justify-center bg-black/40 px-4" role="dialog" aria-modal="true">
|
||||
<div className="w-full max-w-md rounded-2xl border border-[#E5E7EB] bg-white p-6 dark:border-[#1E293B] dark:bg-[#1E293B] shadow-xl">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">Cancelar plan</p>
|
||||
<p className="mt-2 text-lg font-medium text-[#111827] dark:text-[#E2E8F0]">{clienteNombre}</p>
|
||||
<p className="mt-1 text-sm text-[#64748B] dark:text-[#94A3B8]">
|
||||
El saldo pendiente dejará de cobrarse y el plan pasará a CANCELADO.
|
||||
</p>
|
||||
<form
|
||||
className="mt-6 space-y-4"
|
||||
onSubmit={(e: FormEvent) => {
|
||||
e.preventDefault()
|
||||
onConfirmar(motivo, comentario.trim())
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<label htmlFor="motivo-cancel-plan" className="block mb-2 text-xs font-medium text-[#4B5563] dark:text-[#CBD5E1]">Motivo</label>
|
||||
<select
|
||||
id="motivo-cancel-plan"
|
||||
value={motivo}
|
||||
onChange={(e) => setMotivo(e.target.value)}
|
||||
className="w-full rounded-lg border border-[#D1D5DB] bg-white px-3 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] py-2.5 text-sm"
|
||||
>
|
||||
{MOTIVOS_CANCELACION.map((m) => (
|
||||
<option key={m} value={m}>{m === 'Ubicacion' ? 'Ubicación' : m}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="comentario-cancel-plan" className="block mb-2 text-xs font-medium text-[#4B5563] dark:text-[#CBD5E1]">Comentario (opcional)</label>
|
||||
<textarea
|
||||
id="comentario-cancel-plan"
|
||||
value={comentario}
|
||||
onChange={(e) => setComentario(e.target.value)}
|
||||
rows={3}
|
||||
className="w-full rounded-lg border border-[#D1D5DB] bg-white px-3 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] py-2.5 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end gap-3">
|
||||
<button type="button" onClick={onCancelar} disabled={enviando}
|
||||
className="rounded-lg border border-[#D1D5DB] bg-white px-5 py-2 text-sm font-medium text-[#374151] dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] hover:bg-[#F9FAFB] disabled:opacity-50">
|
||||
Cancelar
|
||||
</button>
|
||||
<button type="submit" disabled={enviando}
|
||||
className="rounded-lg bg-red-600 px-5 py-2 text-sm font-medium text-white shadow-sm hover:bg-red-700 disabled:opacity-50">
|
||||
{enviando ? 'Procesando…' : 'Confirmar cancelación'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { request } from '../services/api'
|
||||
import { formatoMoneda } from '../lib/formatters'
|
||||
@@ -29,7 +29,35 @@ function abrirWhatsApp(telefono: string) {
|
||||
window.open(`https://wa.me/${telefonoParaEnlace(telefono)}`, '_blank')
|
||||
}
|
||||
|
||||
export default function MoraDashboard() {
|
||||
/** Título del reporte con el alcance visible (toggle "Mis planes / Todos") */
|
||||
function EncabezadoMora({ alcance }: { alcance: AlcanceMora }) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">
|
||||
Reporte de mora
|
||||
</h2>
|
||||
<span
|
||||
className={`rounded-full px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider ${
|
||||
alcance === 'mios' ? 'bg-blue-50 text-blue-700' : 'bg-gray-100 text-gray-600'
|
||||
}`}
|
||||
title={alcance === 'mios' ? 'Solo los planes de tu alcance' : 'Todos los planes del sistema'}
|
||||
>
|
||||
{alcance === 'mios' ? 'Mis planes' : 'Global'}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Alcance del reporte: los planes cargados en el dashboard ("mios") o todo */
|
||||
type AlcanceMora = 'mios' | 'global'
|
||||
|
||||
interface Props {
|
||||
alcance?: AlcanceMora
|
||||
/** ids de los planes del alcance actual (los mismos que lista la tabla de planes) */
|
||||
planIds?: Set<string>
|
||||
}
|
||||
|
||||
export default function MoraDashboard({ alcance = 'global', planIds }: Props) {
|
||||
const navigate = useNavigate()
|
||||
const [datos, setDatos] = useState<MoraResponse | null>(null)
|
||||
const [cargando, setCargando] = useState(true)
|
||||
@@ -50,6 +78,22 @@ export default function MoraDashboard() {
|
||||
|
||||
useEffect(() => { void cargar() }, [cargar])
|
||||
|
||||
// El endpoint /reportes/mora es global: con alcance "mios" se filtra por los
|
||||
// planes cargados en el dashboard y los KPIs se recalculan sobre esas filas.
|
||||
const filas = useMemo(() => {
|
||||
if (!datos) return []
|
||||
if (alcance !== 'mios' || !planIds) return datos.clientes
|
||||
return datos.clientes.filter((c) => planIds.has(c.planId))
|
||||
}, [datos, alcance, planIds])
|
||||
const resumen = useMemo(
|
||||
() => ({
|
||||
total: filas.length,
|
||||
monto: Math.round(filas.reduce((acc, c) => acc + c.monto, 0) * 100) / 100,
|
||||
promedio: filas.length > 0 ? Math.round(filas.reduce((acc, c) => acc + c.diasAtraso, 0) / filas.length) : 0,
|
||||
}),
|
||||
[filas],
|
||||
)
|
||||
|
||||
if (cargando) {
|
||||
return (
|
||||
<div className="flex h-32 items-center justify-center rounded-xl border border-[#E5E7EB] bg-white dark:border-[#1E293B] dark:bg-[#1E293B]">
|
||||
@@ -69,23 +113,31 @@ export default function MoraDashboard() {
|
||||
)
|
||||
}
|
||||
|
||||
if (!datos || datos.clientes.length === 0) {
|
||||
if (!datos || filas.length === 0) {
|
||||
return (
|
||||
<div className="rounded-xl border border-[#E5E7EB] bg-white p-8 text-center dark:border-[#1E293B] dark:bg-[#1E293B] shadow-sm">
|
||||
<p className="text-sm text-[#64748B] dark:text-[#94A3B8]">No hay clientes con pagos vencidos</p>
|
||||
<div>
|
||||
<EncabezadoMora alcance={alcance} />
|
||||
<div className="rounded-xl border border-[#E5E7EB] bg-white p-8 text-center dark:border-[#1E293B] dark:bg-[#1E293B] shadow-sm">
|
||||
<p className="text-sm text-[#64748B] dark:text-[#94A3B8]">
|
||||
{alcance === 'mios'
|
||||
? 'No hay clientes con pagos vencidos en tu alcance'
|
||||
: 'No hay clientes con pagos vencidos'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<EncabezadoMora alcance={alcance} />
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<KpiCard titulo="Total morosos" valor={datos.clientesConMora} />
|
||||
<KpiCard titulo="Monto vencido" valor={formatoMoneda(datos.montoTotalVencido)} />
|
||||
<KpiCard titulo="Días promedio atraso" valor={datos.diasPromedioAtraso} />
|
||||
<KpiCard titulo="Total morosos" valor={resumen.total} />
|
||||
<KpiCard titulo="Monto vencido" valor={formatoMoneda(resumen.monto)} />
|
||||
<KpiCard titulo="Días promedio atraso" valor={resumen.promedio} />
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto rounded-xl border border-[#E5E7EB] bg-white shadow dark:border-[#1E293B] dark:bg-[#1E293B]">
|
||||
<div className="overflow-x-auto rounded-xl border border-[#E5E7EB] bg-white shadow-sm dark:border-[#1E293B] dark:bg-[#1E293B]">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-[#E5E7EB] bg-[#F8FAFC] text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">
|
||||
@@ -99,7 +151,7 @@ export default function MoraDashboard() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-[#E5E7EB]">
|
||||
{datos.clientes.map((c) => (
|
||||
{filas.map((c) => (
|
||||
<tr key={`${c.planId}-${c.pagoVencido}`} className="transition-colors hover:bg-[#F8FAFC] dark:hover:bg-[#0F172A]">
|
||||
<td className="px-4 py-3 font-medium text-[#111827] dark:text-[#E2E8F0]">{c.clienteNombre}</td>
|
||||
<td className="px-4 py-3 text-[#64748B] dark:text-[#94A3B8]">{c.unidadCodigo}</td>
|
||||
|
||||
34
apps/cobranza/src/lib/useHoy.ts
Normal file
34
apps/cobranza/src/lib/useHoy.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
function esMismoDia(a: Date, b: Date): boolean {
|
||||
return (
|
||||
a.getFullYear() === b.getFullYear() &&
|
||||
a.getMonth() === b.getMonth() &&
|
||||
a.getDate() === b.getDate()
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Fecha "hoy" para cálculos de cobranza (atrasos, vencimientos, mora). Se
|
||||
* recalcula al enfocar la ventana o al volver a la pestaña, de modo que dejar
|
||||
* la app abierta de un día para otro no muestre atrasos del día congelado.
|
||||
* Devuelve la misma instancia mientras no cambie el día (render estable).
|
||||
*/
|
||||
export function useHoy(): Date {
|
||||
const [hoy, setHoy] = useState(() => new Date())
|
||||
|
||||
useEffect(() => {
|
||||
const actualizar = () => {
|
||||
const ahora = new Date()
|
||||
setHoy((prev) => (esMismoDia(prev, ahora) ? prev : ahora))
|
||||
}
|
||||
window.addEventListener('focus', actualizar)
|
||||
document.addEventListener('visibilitychange', actualizar)
|
||||
return () => {
|
||||
window.removeEventListener('focus', actualizar)
|
||||
document.removeEventListener('visibilitychange', actualizar)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return hoy
|
||||
}
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
sumarMeses,
|
||||
} from '../lib/cobranza'
|
||||
import { formatoFechaCorta, formatoMoneda, formatoPorcentaje } from '../lib/formatters'
|
||||
import { useHoy } from '../lib/useHoy'
|
||||
import MoraDashboard from "../components/MoraDashboard"
|
||||
import KpiCard from '../components/KpiCard'
|
||||
import SemaforoDot, { semaforoPlan } from '../components/Semaforo'
|
||||
@@ -62,7 +63,9 @@ export default function DashboardPage() {
|
||||
// param, los vendedores arrancan en "Mis planes" y el resto en "Todos"
|
||||
const paramVista = searchParams.get('vista')
|
||||
const mios = paramVista !== null ? paramVista === 'mios' : miosPorDefecto(getSesion()?.usuario.rol)
|
||||
const hoy = useMemo(() => new Date(), [])
|
||||
// "Hoy" se recalcula al enfocar la ventana (evita atrasos congelados si la
|
||||
// pestaña queda abierta de un día para otro)
|
||||
const hoy = useHoy()
|
||||
|
||||
useEffect(() => {
|
||||
let activo = true
|
||||
@@ -81,6 +84,7 @@ export default function DashboardPage() {
|
||||
}, [mios])
|
||||
|
||||
const planes = useMemo(() => resultado?.planes ?? [], [resultado])
|
||||
const idsPlan = useMemo(() => new Set(planes.map((p) => p.id)), [planes])
|
||||
|
||||
const actualizarParams = (cambios: Record<string, string | null>) => {
|
||||
setSearchParams(
|
||||
@@ -237,7 +241,7 @@ export default function DashboardPage() {
|
||||
)}
|
||||
|
||||
{/* KPIs */}
|
||||
<div className="grid grid-cols-2 gap-4 md:grid-cols-3 md:gap-6 lg:grid-cols-4 xl:grid-cols-5">
|
||||
<div className="grid grid-cols-2 gap-4 md:grid-cols-3 md:gap-6 lg:grid-cols-4">
|
||||
<KpiCard titulo="Planes activos" valor={activos.length} />
|
||||
<KpiCard
|
||||
titulo="Al corriente / con atraso"
|
||||
@@ -295,7 +299,7 @@ export default function DashboardPage() {
|
||||
</div>
|
||||
|
||||
{/* Tarjeta unificada: toolbar de filtros/búsqueda + tabla de planes */}
|
||||
<div className="overflow-hidden rounded-xl border border-[#E5E7EB] bg-white shadow dark:border-[#1E293B] dark:bg-[#1E293B] -sm">
|
||||
<div className="overflow-hidden rounded-xl border border-[#E5E7EB] bg-white shadow-sm dark:border-[#1E293B] dark:bg-[#1E293B]">
|
||||
{/* Filtros */}
|
||||
<div className="flex flex-wrap items-center gap-3 border-b border-[#E5E7EB] p-4 md:gap-4 md:px-5">
|
||||
{/* Toggle Mis planes / Todos (?mios=1 filtra en el API por el vendedor del token) */}
|
||||
@@ -426,7 +430,7 @@ export default function DashboardPage() {
|
||||
</div>
|
||||
</div>
|
||||
<section className="mt-8">
|
||||
<MoraDashboard />
|
||||
<MoraDashboard alcance={mios ? 'mios' : 'global'} planIds={idsPlan} />
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -4,13 +4,16 @@ import { Link } from 'react-router-dom'
|
||||
import { listarPlanes, type ResultadoPlanes } from '../services/repository'
|
||||
import { cobranzaDelDia, round2, statusPago } from '../lib/cobranza'
|
||||
import { formatoFechaCorta, formatoMoneda } from '../lib/formatters'
|
||||
import { useHoy } from '../lib/useHoy'
|
||||
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 [resultado, setResultado] = useState<ResultadoPlanes | null>(null)
|
||||
const [errorCarga, setErrorCarga] = useState<string | null>(null)
|
||||
const hoy = useMemo(() => new Date(), [])
|
||||
// "Hoy" se recalcula al enfocar la ventana (evita atrasos congelados si la
|
||||
// pestaña queda abierta de un día para otro)
|
||||
const hoy = useHoy()
|
||||
|
||||
useEffect(() => {
|
||||
let activo = true
|
||||
@@ -63,7 +66,7 @@ export default function HoyPage() {
|
||||
<KpiCard titulo="Monto por cobrar" valor={formatoMoneda(totalPorCobrar)} />
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto rounded-xl border border-[#E5E7EB] bg-white shadow dark:border-[#1E293B] dark:bg-[#1E293B] -sm">
|
||||
<div className="overflow-x-auto rounded-xl border border-[#E5E7EB] bg-white shadow-sm dark:border-[#1E293B] dark:bg-[#1E293B]">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-[#E5E7EB] bg-[#F8FAFC] text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Fragment, useEffect, useMemo, useState } from 'react'
|
||||
import { Fragment, useEffect, useState } from 'react'
|
||||
import { Link, useNavigate, useParams } from 'react-router-dom'
|
||||
import type { Pago, PlanPago } from '../types'
|
||||
import {
|
||||
@@ -22,9 +22,11 @@ import {
|
||||
import { formatoFechaCorta, formatoMoneda, formatoPorcentaje } from '../lib/formatters'
|
||||
import { descargar } from '../lib/pdf/estadoCuenta'
|
||||
import { descargarRecibo } from '../lib/pdf/reciboAbono'
|
||||
import { useHoy } from '../lib/useHoy'
|
||||
import BadgeStatus from '../components/BadgeStatus'
|
||||
import KpiCard from '../components/KpiCard'
|
||||
import ModalAbono from '../components/ModalAbono'
|
||||
import ModalCancelarPlan from '../components/ModalCancelarPlan'
|
||||
import TimelinePagos from '../components/TimelinePagos'
|
||||
|
||||
export default function PlanDetallePage() {
|
||||
@@ -35,11 +37,15 @@ export default function PlanDetallePage() {
|
||||
const [offline, setOffline] = useState(false)
|
||||
const [errorCarga, setErrorCarga] = useState<string | null>(null)
|
||||
const [pagoAbono, setPagoAbono] = useState<Pago | null>(null)
|
||||
const [modalCancelar, setModalCancelar] = useState(false)
|
||||
const [cancelando, setCancelando] = useState(false)
|
||||
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(), [])
|
||||
// "Hoy" se recalcula al enfocar la ventana (evita atrasos/mora congelados si
|
||||
// la pestaña queda abierta de un día para otro)
|
||||
const hoy = useHoy()
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) {
|
||||
@@ -74,7 +80,7 @@ export default function PlanDetallePage() {
|
||||
|
||||
if (!plan) {
|
||||
return (
|
||||
<div className="rounded-xl border border-[#E5E7EB] bg-white p- dark:border-[#1E293B] dark:bg-[#1E293B] 16 text-center shadow-sm">
|
||||
<div className="rounded-xl border border-[#E5E7EB] bg-white p-16 text-center shadow-sm dark:border-[#1E293B] dark:bg-[#1E293B]">
|
||||
<p className="text-[#64748B] dark:text-[#94A3B8]">Plan no encontrado.</p>
|
||||
<Link to="/" className="mt-4 inline-block text-accent hover:underline">
|
||||
Volver al dashboard
|
||||
@@ -111,14 +117,27 @@ export default function PlanDetallePage() {
|
||||
}
|
||||
}
|
||||
|
||||
const cancelar = async () => {
|
||||
if (!window.confirm('¿Cancelar este plan? El saldo pendiente dejará de cobrarse.')) return
|
||||
/** Cancela el plan y registra el motivo (+comentario) en la bitácora */
|
||||
const confirmarCancelacion = async (motivo: string, comentario: string) => {
|
||||
if (!plan) return
|
||||
setCancelando(true)
|
||||
try {
|
||||
const r = await cancelarPlanAsync(plan.id)
|
||||
setPlan(r.plan)
|
||||
setOffline(r.origen === 'local')
|
||||
setError(null)
|
||||
setCancelando(false)
|
||||
setModalCancelar(false)
|
||||
// El motivo queda asentado en la bitácora (best-effort)
|
||||
const detalle = `Plan cancelado. Motivo: ${motivo}${comentario !== '' ? ` — ${comentario}` : ''}`
|
||||
try {
|
||||
const rn = await agregarNotaAsync(r.plan, detalle)
|
||||
setPlan(rn.plan)
|
||||
} catch {
|
||||
// la cancelación ya se aplicó; la nota es complementaria
|
||||
}
|
||||
} catch (e) {
|
||||
setCancelando(false)
|
||||
setError(e instanceof Error ? e.message : 'Error al cancelar el plan')
|
||||
}
|
||||
}
|
||||
@@ -244,7 +263,7 @@ export default function PlanDetallePage() {
|
||||
</section>
|
||||
|
||||
{/* Tabla de pagos */}
|
||||
<div className="overflow-x-auto rounded-xl border border-[#E5E7EB] bg-white shadow dark:border-[#1E293B] dark:bg-[#1E293B] -sm">
|
||||
<div className="overflow-x-auto rounded-xl border border-[#E5E7EB] bg-white shadow-sm dark:border-[#1E293B] dark:bg-[#1E293B]">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-[#E5E7EB] bg-[#F8FAFC] text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">
|
||||
@@ -393,7 +412,7 @@ export default function PlanDetallePage() {
|
||||
</button>
|
||||
{status === 'ACTIVO' && saldo > 0 && (
|
||||
<button
|
||||
onClick={cancelar}
|
||||
onClick={() => setModalCancelar(true)}
|
||||
className="w-full rounded-lg border border-red-600 bg-white px-5 py-2.5 text-sm font-medium text-red-600 transition-all duration-100 ease-out hover:bg-red-600 hover:text-white"
|
||||
>
|
||||
Cancelar plan
|
||||
@@ -477,6 +496,15 @@ export default function PlanDetallePage() {
|
||||
{pagoAbono && (
|
||||
<ModalAbono pago={pagoAbono} onCancelar={() => setPagoAbono(null)} onGuardar={guardarAbono} />
|
||||
)}
|
||||
|
||||
{modalCancelar && plan && (
|
||||
<ModalCancelarPlan
|
||||
clienteNombre={plan.clienteNombre}
|
||||
enviando={cancelando}
|
||||
onCancelar={() => setModalCancelar(false)}
|
||||
onConfirmar={(motivo, comentario) => void confirmarCancelacion(motivo, comentario)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user