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:
@@ -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]">
|
||||
|
||||
Reference in New Issue
Block a user