feat(pendientes): cotizador (mensualidades, bloqueo de unidad, totales, numMensualidades), cobranza (recibos, atrasos, mora configurable, recordatorios), soporte (adjuntos, filtros, estados), clientes maestro (catalogos reales), contactos (ficha y duplicados) y permisos de captura por rol

This commit is contained in:
2026-09-21 08:34:53 +00:00
parent b5a20aed4e
commit 2fc80e7f9e
66 changed files with 3792 additions and 436 deletions

View File

@@ -2,11 +2,14 @@ import { Fragment, useEffect, useState } from 'react'
import { Link, useNavigate, useParams } from 'react-router-dom'
import type { Pago, PlanPago } from '../types'
import {
actualizarPlanAsync,
agregarNotaAsync,
cancelarPlanAsync,
obtenerPlanPorId,
puedeEditarMora,
registrarAbonoAsync,
} from '../services/repository'
import { getSesion } from '../services/auth'
import {
avancePlan,
diasAtraso,
@@ -43,6 +46,12 @@ export default function PlanDetallePage() {
const [error, setError] = useState<string | null>(null)
const [notaTexto, setNotaTexto] = useState('')
const [errorNota, setErrorNota] = useState<string | null>(null)
// Captura de la tasa de mora (vive en el plan, editable por admin/manager/dirección)
const [tasaInput, setTasaInput] = useState('0')
const [guardandoMora, setGuardandoMora] = useState(false)
const [errorMora, setErrorMora] = useState<string | null>(null)
const [avisoMora, setAvisoMora] = useState<string | null>(null)
const puedeEditarTasa = puedeEditarMora(getSesion()?.usuario.rol)
// "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()
@@ -70,6 +79,12 @@ export default function PlanDetallePage() {
}
}, [id])
// La caja de la tasa se prellena al cargar el plan (o 0 si no tiene). No se
// reinicia con cada recarga del plan para no borrar lo que el usuario escribe.
useEffect(() => {
if (plan) setTasaInput(String(plan.tasaMoraMensual ?? 0))
}, [plan?.id])
if (cargando) {
return <p className="text-sm text-[#64748B] dark:text-[#94A3B8]">Cargando...</p>
}
@@ -94,6 +109,11 @@ export default function PlanDetallePage() {
const atraso = diasAtraso(plan, hoy)
const tasaMora = plan.tasaMoraMensual ?? 0
const mora = totalMora(plan, hoy)
// Mora que resultaría con lo capturado en la caja (vista previa antes de guardar)
const tasaCapturada = Number(tasaInput)
const tasaCapturadaValida = Number.isFinite(tasaCapturada) && tasaCapturada >= 0 && tasaCapturada <= 100
const moraConTasaCapturada = tasaCapturadaValida ? totalMora({ ...plan, tasaMoraMensual: tasaCapturada }, hoy) : 0
const tasaSinCambios = tasaCapturadaValida && round2(tasaCapturada) === round2(tasaMora)
const toggleExpandido = (pagoId: string) => {
setExpandidos((prev) => {
@@ -154,6 +174,31 @@ export default function PlanDetallePage() {
}
}
/** Guarda la tasa de mora mensual del plan (PATCH /planes/:id) */
const guardarTasaMora = async () => {
if (!tasaCapturadaValida) {
setErrorMora('Captura una tasa mensual entre 0 y 100 (%)')
return
}
setGuardandoMora(true)
setErrorMora(null)
setAvisoMora(null)
try {
const r = await actualizarPlanAsync(plan.id, { tasaMoraMensual: round2(tasaCapturada) })
setPlan(r.plan)
setOffline(r.origen === 'local')
setAvisoMora(
r.origen === 'local'
? 'Sin conexión al API: la tasa se guardó solo en este navegador.'
: 'Tasa de mora actualizada.',
)
} catch (e) {
setErrorMora(e instanceof Error ? e.message : 'Error al guardar la tasa de mora')
} finally {
setGuardandoMora(false)
}
}
const avance = avancePlan(plan)
/** Siguiente pago sin cubrir (futuro próximo o vencido más antiguo) */
@@ -175,7 +220,9 @@ export default function PlanDetallePage() {
<h1 className="mt-2 text-xl font-semibold text-[#111827] dark:text-[#E2E8F0]">{plan.clienteNombre}</h1>
<p className="mt-1 text-sm text-[#64748B] dark:text-[#94A3B8]">
{plan.unidadCodigo} · {plan.proyecto} · Venta {plan.ventaRef || '—'} ·{' '}
<span className="font-semibold text-[#111827] dark:text-[#E2E8F0]">{status}</span>
<span className="font-semibold text-[#111827] dark:text-[#E2E8F0]">
{status === 'SIN_PLAN' ? 'SIN PLAN CAPTURADO' : status}
</span>
{atraso > 0 && <span className="text-red-700"> · {atraso} días de atraso</span>}
</p>
<p className="mt-1 text-sm text-[#64748B] dark:text-[#94A3B8]">
@@ -201,6 +248,14 @@ export default function PlanDetallePage() {
</p>
)}
{status === 'SIN_PLAN' && (
<div className="rounded-lg border border-orange-200 bg-orange-50 px-4 py-3 text-sm text-orange-800">
<strong>Sin plan capturado:</strong> este plan no tiene calendario de pagos (enganche y
mensualidades en cero), así que no aparece en “Cobranza del día” ni en el reporte de mora. No es
un plan liquidado: hay que capturar su calendario para poder cobrarlo.
</div>
)}
{error && (
<div className="rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{error}</div>
)}
@@ -421,6 +476,75 @@ export default function PlanDetallePage() {
</div>
</section>
{/* Mora del plan: la tasa vive en el plan y la editan admin/manager/dirección */}
<section
className="rounded-xl border border-[#E5E7EB] bg-white p-5 dark:border-[#1E293B] dark:bg-[#1E293B] shadow-sm"
aria-labelledby="titulo-mora"
>
<h2 id="titulo-mora" className="text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">
Mora por atraso
</h2>
{puedeEditarTasa ? (
<form
className="mt-4"
onSubmit={(e) => {
e.preventDefault()
void guardarTasaMora()
}}
>
<label htmlFor="tasa-mora" className="block text-xs font-medium text-[#4B5563] dark:text-[#CBD5E1]">
Tasa de mora mensual (%)
</label>
<input
id="tasa-mora"
type="number"
min={0}
max={100}
step="0.01"
inputMode="decimal"
value={tasaInput}
onChange={(e) => {
setTasaInput(e.target.value)
setErrorMora(null)
setAvisoMora(null)
}}
className="mt-1 w-full rounded-lg border border-[#D1D5DB] bg-white px-3 py-2.5 text-sm transition-colors focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/20 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0]"
/>
<p className="mt-1 text-xs text-[#64748B] dark:text-[#94A3B8]">
La tasa es <strong>mensual en %</strong> y se prorratea por día (tasa ÷ 30) sobre el restante
de cada pago vencido, incluidos los parciales vencidos. 0 = sin mora.
</p>
<p className="mt-2 text-xs text-[#64748B] dark:text-[#94A3B8]">
Mora acumulada con esta tasa:{' '}
<span className="font-semibold text-[#111827] dark:text-[#E2E8F0]">
{formatoMoneda(moraConTasaCapturada)}
</span>
</p>
{errorMora && <p className="mt-1 text-xs text-red-700">{errorMora}</p>}
{avisoMora && <p className="mt-1 text-xs text-green-700">{avisoMora}</p>}
<div className="mt-4 flex justify-end">
<button
type="submit"
disabled={guardandoMora || !tasaCapturadaValida || tasaSinCambios}
className="rounded-lg bg-accent px-5 py-2.5 text-sm font-medium text-white shadow-sm transition-all duration-100 ease-out hover:bg-accent-hover disabled:opacity-40"
>
{guardandoMora ? 'Guardando…' : 'Guardar tasa'}
</button>
</div>
</form>
) : (
<div className="mt-4 space-y-2 text-sm text-[#64748B] dark:text-[#94A3B8]">
<p>
Tasa: <span className="font-semibold text-[#111827] dark:text-[#E2E8F0]">{tasaMora}% mensual</span>
</p>
<p>
Mora acumulada: <span className="font-semibold text-[#111827] dark:text-[#E2E8F0]">{formatoMoneda(mora)}</span>
</p>
<p className="text-xs">Solo admin, manager y dirección pueden modificar la tasa.</p>
</div>
)}
</section>
{/* Bitácora del plan */}
<section className="rounded-xl border border-[#E5E7EB] bg-white p-5 dark:border-[#1E293B] dark:bg-[#1E293B] shadow-sm" aria-labelledby="titulo-bitacora">
<h2 id="titulo-bitacora" className="text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">